package bot import ( "context" "errors" "strconv" "strings" "time" storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" "go.uber.org/zap" ) const unauthorizedMessage = "*Unauthorized*\nYour Telegram account is not allowed to perform treasury operations." const unauthorizedChatMessage = "*Unauthorized Chat*\nThis Telegram chat is not allowed to perform treasury operations." const amountInputHint = "*Amount format*\nEnter amount as a decimal number using a dot separator and without currency.\nExample: `1250.75`" type SendTextFunc func(ctx context.Context, chatID string, text string) error type ScheduleTracker interface { TrackScheduled(record *storagemodel.TreasuryRequest) Untrack(requestID string) } type AccountProfile struct { AccountID string AccountCode string Currency string } type CreateRequestInput struct { OperationType storagemodel.TreasuryOperationType TelegramUserID string LedgerAccountID string ChatID string Amount string } type TreasuryService interface { ExecutionDelay() time.Duration MaxPerOperationLimit() string GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error) CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) } type limitError interface { error LimitKind() string LimitMax() string } type Router struct { logger mlogger.Logger service TreasuryService dialogs *Dialogs send SendTextFunc tracker ScheduleTracker allowedChats map[string]struct{} userAccounts map[string]string allowAnyChat bool } func NewRouter( logger mlogger.Logger, service TreasuryService, send SendTextFunc, tracker ScheduleTracker, allowedChats []string, userAccounts map[string]string, ) *Router { if logger != nil { logger = logger.Named("treasury_router") } allowed := map[string]struct{}{} for _, chatID := range allowedChats { chatID = strings.TrimSpace(chatID) if chatID == "" { continue } allowed[chatID] = struct{}{} } users := map[string]string{} for userID, accountID := range userAccounts { userID = strings.TrimSpace(userID) accountID = strings.TrimSpace(accountID) if userID == "" || accountID == "" { continue } users[userID] = accountID } return &Router{ logger: logger, service: service, dialogs: NewDialogs(), send: send, tracker: tracker, allowedChats: allowed, userAccounts: users, allowAnyChat: len(allowed) == 0, } } func (r *Router) Enabled() bool { return r != nil && r.service != nil && len(r.userAccounts) > 0 } func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool { if !r.Enabled() || update == nil || update.Message == nil { return false } message := update.Message chatID := strings.TrimSpace(message.ChatID) userID := strings.TrimSpace(message.FromUserID) text := strings.TrimSpace(message.Text) if chatID == "" || userID == "" { return false } command := parseCommand(text) if r.logger != nil { r.logger.Debug("Telegram treasury update received", zap.Int64("update_id", update.UpdateID), zap.String("chat_id", chatID), zap.String("telegram_user_id", userID), zap.String("command", strings.TrimSpace(string(command))), zap.String("message_text", text), zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)), ) } if !r.allowAnyChat { if _, ok := r.allowedChats[chatID]; !ok { r.logUnauthorized(update) _ = r.sendText(ctx, chatID, unauthorizedChatMessage) return true } } accountID, ok := r.userAccounts[userID] if !ok || strings.TrimSpace(accountID) == "" { r.logUnauthorized(update) _ = r.sendText(ctx, chatID, unauthorizedMessage) return true } switch command { case CommandStart: profile := r.resolveAccountProfile(ctx, accountID) _ = r.sendText(ctx, chatID, welcomeMessage(profile)) return true case CommandHelp: profile := r.resolveAccountProfile(ctx, accountID) _ = r.sendText(ctx, chatID, helpMessage(displayAccountCode(profile, accountID), profile.Currency)) return true case CommandFund: if r.logger != nil { r.logger.Info("Treasury funding dialog requested", zap.String("chat_id", chatID), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID)) } r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund) return true case CommandWithdraw: if r.logger != nil { r.logger.Info("Treasury withdrawal dialog requested", zap.String("chat_id", chatID), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID)) } r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw) return true case CommandConfirm: if r.logger != nil { r.logger.Info("Treasury confirmation requested", zap.String("chat_id", chatID), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID)) } r.confirm(ctx, userID, accountID, chatID) return true case CommandCancel: if r.logger != nil { r.logger.Info("Treasury cancellation requested", zap.String("chat_id", chatID), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID)) } r.cancel(ctx, userID, accountID, chatID) return true } session, hasSession := r.dialogs.Get(userID) if hasSession { switch session.State { case DialogStateWaitingAmount: r.captureAmount(ctx, userID, accountID, chatID, session.OperationType, text) return true case DialogStateWaitingConfirmation: _ = r.sendText(ctx, chatID, confirmationCommandsMessage()) return true } } if strings.HasPrefix(text, "/") { _ = r.sendText(ctx, chatID, supportedCommandsMessage()) return true } if strings.TrimSpace(message.ReplyToMessageID) != "" { return false } if text != "" { _ = r.sendText(ctx, chatID, supportedCommandsMessage()) return true } return false } func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) { active, err := r.service.GetActiveRequestForAccount(ctx, accountID) if err != nil { if r.logger != nil { r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID)) } _ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check pending treasury operations right now. Please try again.") return } if active != nil { _ = r.sendText(ctx, chatID, pendingRequestMessage(active)) r.dialogs.Set(userID, DialogSession{ State: DialogStateWaitingConfirmation, LedgerAccountID: accountID, RequestID: active.RequestID, }) return } r.dialogs.Set(userID, DialogSession{ State: DialogStateWaitingAmount, OperationType: operation, LedgerAccountID: accountID, }) profile := r.resolveAccountProfile(ctx, accountID) _ = r.sendText(ctx, chatID, amountPromptMessage(operation, profile, accountID)) } func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType, amount string) { record, err := r.service.CreateRequest(ctx, CreateRequestInput{ OperationType: operation, TelegramUserID: userID, LedgerAccountID: accountID, ChatID: chatID, Amount: amount, }) if err != nil { if record != nil { _ = r.sendText(ctx, chatID, pendingRequestMessage(record)) r.dialogs.Set(userID, DialogSession{ State: DialogStateWaitingConfirmation, LedgerAccountID: accountID, RequestID: record.RequestID, }) return } if typed, ok := err.(limitError); ok { switch typed.LimitKind() { case "per_operation": _ = r.sendText(ctx, chatID, "*Amount exceeds allowed limit*\n\n*Max per operation:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return case "daily": _ = r.sendText(ctx, chatID, "*Daily amount limit exceeded*\n\n*Max per day:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return } } if errors.Is(err, merrors.ErrInvalidArg) { _ = r.sendText(ctx, chatID, "*Invalid amount*\n\n"+amountInputHint+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return } _ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return } if record == nil { _ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return } r.dialogs.Set(userID, DialogSession{ State: DialogStateWaitingConfirmation, LedgerAccountID: accountID, RequestID: record.RequestID, }) _ = r.sendText(ctx, chatID, confirmationPrompt(record)) } func (r *Router) confirm(ctx context.Context, userID string, accountID string, chatID string) { requestID := "" if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" { requestID = strings.TrimSpace(session.RequestID) } else { active, err := r.service.GetActiveRequestForAccount(ctx, accountID) if err == nil && active != nil { requestID = strings.TrimSpace(active.RequestID) } } if requestID == "" { _ = r.sendText(ctx, chatID, "*No pending treasury operation.*") return } record, err := r.service.ConfirmRequest(ctx, requestID, userID) if err != nil { _ = r.sendText(ctx, chatID, "*Unable to confirm treasury request.*\n\nUse "+markdownCommand(CommandCancel)+" or create a new request with "+markdownCommand(CommandFund)+" or "+markdownCommand(CommandWithdraw)+".") return } if r.tracker != nil { r.tracker.TrackScheduled(record) } r.dialogs.Clear(userID) delay := int64(r.service.ExecutionDelay().Seconds()) if delay < 0 { delay = 0 } _ = r.sendText(ctx, chatID, "*Operation confirmed*\n\n"+ "*Execution:* scheduled in "+markdownCode(formatSeconds(delay))+".\n"+ "You can cancel during this cooldown with "+markdownCommand(CommandCancel)+".\n\n"+ "You will receive a follow-up message with execution success or failure.\n\n"+ "*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID))) } func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) { requestID := "" if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" { requestID = strings.TrimSpace(session.RequestID) } else { active, err := r.service.GetActiveRequestForAccount(ctx, accountID) if err == nil && active != nil { requestID = strings.TrimSpace(active.RequestID) } } if requestID == "" { r.dialogs.Clear(userID) _ = r.sendText(ctx, chatID, "*No pending treasury operation.*") return } record, err := r.service.CancelRequest(ctx, requestID, userID) if err != nil { _ = r.sendText(ctx, chatID, "*Unable to cancel treasury request.*") return } if r.tracker != nil { r.tracker.Untrack(record.RequestID) } r.dialogs.Clear(userID) _ = r.sendText(ctx, chatID, "*Operation cancelled*\n\n*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID))) } func (r *Router) sendText(ctx context.Context, chatID string, text string) error { if r == nil || r.send == nil { return nil } chatID = strings.TrimSpace(chatID) text = strings.TrimSpace(text) if chatID == "" || text == "" { return nil } if err := r.send(ctx, chatID, text); err != nil { if r.logger != nil { r.logger.Warn("Failed to send treasury bot response", zap.Error(err), zap.String("chat_id", chatID), zap.String("message_text", text)) } return err } return nil } func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) { if r == nil || r.logger == nil || update == nil || update.Message == nil { return } message := update.Message r.logger.Warn("unauthorized_access", zap.String("event", "unauthorized_access"), zap.String("telegram_user_id", strings.TrimSpace(message.FromUserID)), zap.String("chat_id", strings.TrimSpace(message.ChatID)), zap.String("message_text", strings.TrimSpace(message.Text)), zap.Time("timestamp", time.Now()), ) } func pendingRequestMessage(record *storagemodel.TreasuryRequest) string { if record == nil { return "*Pending treasury operation already exists.*\n\nUse " + markdownCommand(CommandCancel) + "." } return "*Pending Treasury Operation*\n\n" + "*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" + "*Request ID:* " + markdownCode(strings.TrimSpace(record.RequestID)) + "\n" + "*Status:* " + markdownCode(strings.TrimSpace(string(record.Status))) + "\n" + "*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" + "Wait for execution or cancel with " + markdownCommand(CommandCancel) + "." } func confirmationPrompt(record *storagemodel.TreasuryRequest) string { if record == nil { return "*Request created.*\n\nUse " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + "." } title := "*Funding request created.*" if record.OperationType == storagemodel.TreasuryOperationWithdraw { title = "*Withdrawal request created.*" } return title + "\n\n" + "*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" + "*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" + confirmationCommandsMessage() } func welcomeMessage(profile *AccountProfile) string { accountCode := displayAccountCode(profile, "") currency := "" if profile != nil { currency = strings.ToUpper(strings.TrimSpace(profile.Currency)) } if accountCode == "" { accountCode = "N/A" } if currency == "" { currency = "N/A" } return "*Sendico Treasury Bot*\n\n" + "*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n" + "Use " + markdownCommand(CommandFund) + " to credit your account and " + markdownCommand(CommandWithdraw) + " to debit it.\n" + "After entering an amount, use " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + ".\n" + "Use " + markdownCommand(CommandHelp) + " for detailed usage." } func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string { title := "*Funding request*" if operation == storagemodel.TreasuryOperationWithdraw { title = "*Withdrawal request*" } accountCode := displayAccountCode(profile, fallbackAccountID) currency := "" if profile != nil { currency = strings.ToUpper(strings.TrimSpace(profile.Currency)) } if accountCode == "" { accountCode = "N/A" } if currency == "" { currency = "N/A" } return title + "\n\n" + "*Account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n\n" + amountInputHint } func requestAccountDisplay(record *storagemodel.TreasuryRequest) string { if record == nil { return "" } if code := strings.TrimSpace(record.LedgerAccountCode); code != "" { return code } return strings.TrimSpace(record.LedgerAccountID) } func displayAccountCode(profile *AccountProfile, fallbackAccountID string) string { if profile != nil { if code := strings.TrimSpace(profile.AccountCode); code != "" { return code } if id := strings.TrimSpace(profile.AccountID); id != "" { return id } } return strings.TrimSpace(fallbackAccountID) } func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID string) *AccountProfile { if r == nil || r.service == nil { return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)} } profile, err := r.service.GetAccountProfile(ctx, ledgerAccountID) if err != nil { if r.logger != nil { r.logger.Warn("Failed to resolve treasury account profile", zap.Error(err), zap.String("ledger_account_id", strings.TrimSpace(ledgerAccountID))) } return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)} } if profile == nil { return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)} } if strings.TrimSpace(profile.AccountID) == "" { profile.AccountID = strings.TrimSpace(ledgerAccountID) } return profile } func formatSeconds(value int64) string { if value == 1 { return "1 second" } return strconv.FormatInt(value, 10) + " seconds" }