+notification from site +version bump fix
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

This commit is contained in:
Stephan D
2025-11-17 22:20:17 +01:00
parent c6a56071b5
commit 9dbf77a9a8
21 changed files with 543 additions and 9 deletions

View File

@@ -53,14 +53,21 @@ api:
password_env: MAIL_SECRET
host: "smtp.mail.ru"
port: 465
from: "MeetX Tech"
from: "Sendico Tech"
network_timeout: 10
telegram:
bot_token_env: TELEGRAM_BOT_TOKEN
chat_id_env: TELEGRAM_CHAT_ID
thread_id_env: TELEGRAM_THREAD_ID
api_url: "https://api.telegram.org"
timeout_seconds: 10
parse_mode: ""
localizer:
path: "./i18n"
languages: ["en", "ru", "uk"]
service_name: "Sendico"
support: "support@meetx.space"
support: "support@sendico.io"
app:
@@ -82,4 +89,4 @@ database:
collection_name_env: PERMISSION_COLLECTION
database_name_env: MONGO_DATABASE
timeout_seconds_env: PERMISSION_TIMEOUT
is_filtered_env: PERMISSION_IS_FILTERED
is_filtered_env: PERMISSION_IS_FILTERED

View File

@@ -1,6 +1,16 @@
package notificationimp
type Config struct {
Driver string `yaml:"driver"`
Settings map[string]any `yaml:"settings,omitempty"`
Driver string `yaml:"driver"`
Settings map[string]any `yaml:"settings,omitempty"`
Telegram *TelegramConfig `yaml:"telegram"`
}
type TelegramConfig struct {
BotTokenEnv string `yaml:"bot_token_env"`
ChatIDEnv string `yaml:"chat_id_env"`
ThreadIDEnv string `yaml:"thread_id_env,omitempty"`
APIURL string `yaml:"api_url,omitempty"`
ParseMode string `yaml:"parse_mode,omitempty"`
TimeoutSeconds int `yaml:"timeout_seconds"`
}

View File

@@ -2,13 +2,17 @@ package notificationimp
import (
"context"
"fmt"
"github.com/tech/sendico/notification/interface/api"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail"
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
"github.com/tech/sendico/pkg/domainprovider"
na "github.com/tech/sendico/pkg/messaging/notifications/account"
ni "github.com/tech/sendico/pkg/messaging/notifications/invitation"
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
@@ -17,6 +21,7 @@ type NotificationAPI struct {
logger mlogger.Logger
client mmail.Client
dp domainprovider.DomainProvider
tg telegram.Client
}
func (a *NotificationAPI) Name() mservice.Type {
@@ -33,11 +38,22 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
}
p.logger = a.Logger().Named(p.Name())
if a.Config().Notification == nil {
return nil, fmt.Errorf("notification configuration is missing")
}
if a.Config().Notification.Telegram == nil {
return nil, fmt.Errorf("telegram configuration is missing")
}
var err error
if p.client, err = mmail.CreateMailClient(p.logger.Named("mailer"), p.Name(), a.Register().Producer(), a.Localizer(), a.DomainProvider(), a.Config().Notification); err != nil {
p.logger.Error("Failed to create mail connection", zap.Error(err), zap.String("driver", a.Config().Notification.Driver))
return nil, err
}
if p.tg, err = telegram.NewClient(p.logger.Named("telegram"), a.Config().Notification.Telegram); err != nil {
p.logger.Error("Failed to create telegram client", zap.Error(err))
return nil, err
}
db, err := a.DBFactory().NewAccountDB()
if err != nil {
@@ -64,5 +80,22 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
return nil, err
}
if err := a.Register().Consumer(snotifications.NewDemoRequestProcessor(p.logger, p.onDemoRequest)); err != nil {
p.logger.Error("Failed to register demo request handler", zap.Error(err))
return nil, err
}
return p, nil
}
func (a *NotificationAPI) onDemoRequest(ctx context.Context, request *model.DemoRequest) error {
if a.tg == nil {
return fmt.Errorf("telegram client is not configured")
}
if err := a.tg.SendDemoRequest(ctx, request); err != nil {
a.logger.Warn("Failed to send demo request via telegram", zap.Error(err))
return err
}
a.logger.Info("Demo request sent via Telegram", zap.String("name", request.Name), zap.String("organization", request.OrganizationName))
return nil
}

View File

@@ -0,0 +1,149 @@
package telegram
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
notconfig "github.com/tech/sendico/notification/interface/services/notification/config"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
const defaultAPIURL = "https://api.telegram.org"
type Client interface {
SendDemoRequest(ctx context.Context, request *model.DemoRequest) error
}
type client struct {
logger mlogger.Logger
httpClient *http.Client
apiURL string
botToken string
chatID string
threadID *int64
parseMode string
}
type sendMessagePayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode,omitempty"`
ThreadID *int64 `json:"message_thread_id,omitempty"`
DisablePreview bool `json:"disable_web_page_preview,omitempty"`
DisableNotify bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"`
}
func NewClient(logger mlogger.Logger, cfg *notconfig.TelegramConfig) (Client, error) {
if cfg == nil {
return nil, fmt.Errorf("telegram configuration is not provided")
}
token := strings.TrimSpace(os.Getenv(cfg.BotTokenEnv))
if token == "" {
return nil, fmt.Errorf("telegram bot token env %s is empty", cfg.BotTokenEnv)
}
chatID := strings.TrimSpace(os.Getenv(cfg.ChatIDEnv))
if chatID == "" {
return nil, fmt.Errorf("telegram chat id env %s is empty", cfg.ChatIDEnv)
}
var threadID *int64
if env := strings.TrimSpace(cfg.ThreadIDEnv); env != "" {
raw := strings.TrimSpace(os.Getenv(env))
if raw != "" {
val, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, fmt.Errorf("telegram thread id env %s is invalid: %w", env, err)
}
threadID = &val
}
}
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
apiURL := strings.TrimSpace(cfg.APIURL)
if apiURL == "" {
apiURL = defaultAPIURL
}
return &client{
logger: logger.Named("telegram"),
httpClient: &http.Client{
Timeout: timeout,
},
apiURL: strings.TrimRight(apiURL, "/"),
botToken: token,
chatID: chatID,
threadID: threadID,
parseMode: strings.TrimSpace(cfg.ParseMode),
}, nil
}
func (c *client) SendDemoRequest(ctx context.Context, request *model.DemoRequest) error {
if request == nil {
return fmt.Errorf("demo request payload is nil")
}
message := buildMessage(request)
payload := sendMessagePayload{
ChatID: c.chatID,
Text: message,
ParseMode: c.parseMode,
ThreadID: c.threadID,
DisablePreview: true,
}
return c.sendMessage(ctx, payload)
}
func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) error {
body, err := json.Marshal(&payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return nil
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return fmt.Errorf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody))
}
func (c *client) endpoint() string {
return fmt.Sprintf("%s/bot%s/sendMessage", c.apiURL, c.botToken)
}
func buildMessage(req *model.DemoRequest) string {
var builder strings.Builder
builder.WriteString("New demo request received\n")
builder.WriteString(fmt.Sprintf("Name: %s\n", req.Name))
builder.WriteString(fmt.Sprintf("Organization: %s\n", req.OrganizationName))
builder.WriteString(fmt.Sprintf("Phone: %s\n", req.Phone))
builder.WriteString(fmt.Sprintf("Work email: %s\n", req.WorkEmail))
builder.WriteString(fmt.Sprintf("Payout volume: %s\n", req.PayoutVolume))
if req.Comment != "" {
builder.WriteString(fmt.Sprintf("Comment: %s\n", req.Comment))
}
return builder.String()
}

View File

@@ -0,0 +1,47 @@
package notifications
import (
"fmt"
gmessaging "github.com/tech/sendico/pkg/generated/gmessaging"
messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
"google.golang.org/protobuf/proto"
)
type DemoRequestNotification struct {
messaging.Envelope
request *model.DemoRequest
}
func (drn *DemoRequestNotification) Serialize() ([]byte, error) {
if drn.request == nil {
return nil, fmt.Errorf("demo request payload is empty")
}
msg := gmessaging.DemoRequestEvent{
Name: drn.request.Name,
OrganizationName: drn.request.OrganizationName,
Phone: drn.request.Phone,
WorkEmail: drn.request.WorkEmail,
PayoutVolume: drn.request.PayoutVolume,
Comment: drn.request.Comment,
}
data, err := proto.Marshal(&msg)
if err != nil {
return nil, err
}
return drn.Envelope.Wrap(data)
}
func NewDemoRequestEvent() model.NotificationEvent {
return model.NewNotification(mservice.Site, nm.NACreated)
}
func NewDemoRequestEnvelope(sender string, request *model.DemoRequest) messaging.Envelope {
return &DemoRequestNotification{
Envelope: messaging.CreateEnvelope(sender, NewDemoRequestEvent()),
request: request,
}
}

View File

@@ -0,0 +1,11 @@
package notifications
import (
messaging "github.com/tech/sendico/pkg/messaging/envelope"
internalsite "github.com/tech/sendico/pkg/messaging/internal/notifications/site"
"github.com/tech/sendico/pkg/model"
)
func DemoRequest(sender string, request *model.DemoRequest) messaging.Envelope {
return internalsite.NewDemoRequestEnvelope(sender, request)
}

View File

@@ -0,0 +1,9 @@
package notifications
import (
"context"
"github.com/tech/sendico/pkg/model"
)
type DemoRequestHandler = func(context.Context, *model.DemoRequest) error

View File

@@ -0,0 +1,50 @@
package notifications
import (
"context"
gmessaging "github.com/tech/sendico/pkg/generated/gmessaging"
me "github.com/tech/sendico/pkg/messaging/envelope"
internalsite "github.com/tech/sendico/pkg/messaging/internal/notifications/site"
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
handler "github.com/tech/sendico/pkg/messaging/notifications/site/handler"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
)
type DemoRequestProcessor struct {
logger mlogger.Logger
handler handler.DemoRequestHandler
event model.NotificationEvent
}
func (drp *DemoRequestProcessor) Process(ctx context.Context, envelope me.Envelope) error {
var msg gmessaging.DemoRequestEvent
if err := proto.Unmarshal(envelope.GetData(), &msg); err != nil {
drp.logger.Warn("Failed to decode demo request envelope", zap.Error(err), zap.String("topic", drp.event.ToString()))
return err
}
request := &model.DemoRequest{
Name: msg.GetName(),
OrganizationName: msg.GetOrganizationName(),
Phone: msg.GetPhone(),
WorkEmail: msg.GetWorkEmail(),
PayoutVolume: msg.GetPayoutVolume(),
Comment: msg.GetComment(),
}
return drp.handler(ctx, request)
}
func (drp *DemoRequestProcessor) GetSubject() model.NotificationEvent {
return drp.event
}
func NewDemoRequestProcessor(logger mlogger.Logger, handler handler.DemoRequestHandler) np.EnvelopeProcessor {
return &DemoRequestProcessor{
logger: logger.Named("demo_request_processor"),
handler: handler,
event: internalsite.NewDemoRequestEvent(),
}
}

View File

@@ -0,0 +1,53 @@
package model
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
)
// DemoRequest represents a request submitted from the marketing site to request a demo.
type DemoRequest struct {
Name string `json:"name"`
OrganizationName string `json:"organizationName"`
Phone string `json:"phone"`
WorkEmail string `json:"workEmail"`
PayoutVolume string `json:"payoutVolume"`
Comment string `json:"comment,omitempty"`
}
// Normalize trims whitespace from all string fields.
func (dr *DemoRequest) Normalize() {
if dr == nil {
return
}
dr.Name = strings.TrimSpace(dr.Name)
dr.OrganizationName = strings.TrimSpace(dr.OrganizationName)
dr.Phone = strings.TrimSpace(dr.Phone)
dr.WorkEmail = strings.TrimSpace(dr.WorkEmail)
dr.PayoutVolume = strings.TrimSpace(dr.PayoutVolume)
dr.Comment = strings.TrimSpace(dr.Comment)
}
// Validate ensures that all required fields are present.
func (dr *DemoRequest) Validate() error {
if dr == nil {
return merrors.InvalidArgument("request payload is empty")
}
if dr.Name == "" {
return merrors.InvalidArgument("name must not be empty")
}
if dr.OrganizationName == "" {
return merrors.InvalidArgument("organization name must not be empty")
}
if dr.Phone == "" {
return merrors.InvalidArgument("phone must not be empty")
}
if dr.WorkEmail == "" {
return merrors.InvalidArgument("work email must not be empty")
}
if dr.PayoutVolume == "" {
return merrors.InvalidArgument("payout volume must not be empty")
}
return nil
}

View File

@@ -0,0 +1,31 @@
package model
import "testing"
func TestDemoRequestNormalizeAndValidate(t *testing.T) {
req := &DemoRequest{
Name: " Alice ",
OrganizationName: " Sendico ",
Phone: " +1 234 ",
WorkEmail: " demo@sendico.io ",
PayoutVolume: " 100k ",
Comment: " Excited ",
}
req.Normalize()
if err := req.Validate(); err != nil {
t.Fatalf("expected request to be valid, got error: %v", err)
}
if req.Name != "Alice" || req.OrganizationName != "Sendico" || req.Phone != "+1 234" || req.WorkEmail != "demo@sendico.io" || req.PayoutVolume != "100k" || req.Comment != "Excited" {
t.Fatalf("normalize failed: %+v", req)
}
}
func TestDemoRequestValidateMissing(t *testing.T) {
req := &DemoRequest{}
req.Normalize()
if err := req.Validate(); err == nil {
t.Fatalf("expected validation error for empty request")
}
}

View File

@@ -7,6 +7,7 @@ type Type = string
const (
Accounts Type = "accounts" // Represents user accounts in the system
Amplitude Type = "amplitude" // Represents analytics integration with Amplitude
Site Type = "site" // Represents public site endpoints
Automations Type = "automation" // Represents automation workflows
Changes Type = "changes" // Tracks changes made to resources
Clients Type = "clients" // Represents client information
@@ -59,7 +60,7 @@ const (
func StringToSType(s string) (Type, error) {
switch Type(s) {
case Accounts, Amplitude, Automations, Changes, Clients, Comments, ChainGateway, ChainWallets, ChainWalletBalances,
case Accounts, Amplitude, Site, Automations, Changes, Clients, Comments, ChainGateway, ChainWallets, ChainWalletBalances,
ChainTransfers, ChainDeposits, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger,
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
Organizations, Payments, PaymentOrchestrator, Permissions, Policies, PolicyAssignements, Priorities,

View File

@@ -0,0 +1,12 @@
syntax = "proto3";
option go_package = "github.com/tech/sendico/pkg/generated/gmessaging";
message DemoRequestEvent {
string Name = 1;
string OrganizationName = 2;
string Phone = 3;
string WorkEmail = 4;
string PayoutVolume = 5;
string Comment = 6;
}

View File

@@ -0,0 +1,11 @@
package site
import (
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/siteimp"
)
func Create(a eapi.API) (mservice.MicroService, error) {
return siteimp.CreateAPI(a)
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/tech/sendico/server/interface/services/logo"
"github.com/tech/sendico/server/interface/services/organization"
"github.com/tech/sendico/server/interface/services/permission"
"github.com/tech/sendico/server/interface/services/site"
"go.uber.org/zap"
)
@@ -79,6 +80,7 @@ func (a *APIImp) installServices() error {
srvf = append(srvf, invitation.Create)
srvf = append(srvf, logo.Create)
srvf = append(srvf, permission.Create)
srvf = append(srvf, site.Create)
for _, v := range srvf {
if err := a.addMicroservice(v); err != nil {

View File

@@ -0,0 +1,60 @@
package siteimp
import (
"context"
"encoding/json"
"net/http"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/messaging"
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"go.uber.org/zap"
)
type SiteAPI struct {
logger mlogger.Logger
producer messaging.Producer
}
func (a *SiteAPI) Name() mservice.Type {
return mservice.Site
}
func (a *SiteAPI) Finish(_ context.Context) error {
return nil
}
func (a *SiteAPI) demoRequest(r *http.Request) http.HandlerFunc {
var request model.DemoRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
a.logger.Warn("Failed to decode demo request payload", zap.Error(err))
return response.BadRequest(a.logger, a.Name(), "invalid_payload", "Failed to decode demo request payload")
}
request.Normalize()
if err := request.Validate(); err != nil {
a.logger.Warn("Demo request validation failed", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := a.producer.SendMessage(snotifications.DemoRequest(a.Name(), &request)); err != nil {
a.logger.Warn("Failed to enqueue demo request notification", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
return response.Accepted(a.logger, map[string]string{"status": "queued"})
}
func CreateAPI(a eapi.API) (*SiteAPI, error) {
p := &SiteAPI{
logger: a.Logger().Named(mservice.Site),
producer: a.Register().Messaging().Producer(),
}
a.Register().Handler(mservice.Site, "/demo/request", api.Post, p.demoRequest)
return p, nil
}