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 = "Sorry, your Telegram account is not authorized to perform treasury operations." const unauthorizedChatMessage = "Sorry, this Telegram chat is not authorized to perform treasury operations." const amountInputHint = "Enter 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, "Unable 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\nMax per operation: "+typed.LimitMax()+"\n\nEnter another amount or "+CommandCancel.Slash()) return case "daily": _ = r.sendText(ctx, chatID, "Daily amount limit exceeded.\n\nMax per day: "+typed.LimitMax()+"\n\nEnter another amount or "+CommandCancel.Slash()) return } } if errors.Is(err, merrors.ErrInvalidArg) { _ = r.sendText(ctx, chatID, "Invalid amount.\n\n"+amountInputHint+"\n\nEnter another amount or "+CommandCancel.Slash()) return } _ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or "+CommandCancel.Slash()) return } if record == nil { _ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or "+CommandCancel.Slash()) 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 "+CommandCancel.Slash()+" or create a new request with "+CommandFund.Slash()+" or "+CommandWithdraw.Slash()+".") 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\nExecution scheduled in "+formatSeconds(delay)+".\nYou can cancel during this cooldown with "+CommandCancel.Slash()+".\n\nYou will receive a follow-up message with execution success or failure.\n\nRequest ID: "+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\nRequest ID: "+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 "You already have a pending treasury operation.\n\n" + CommandCancel.Slash() } return "You already have a pending treasury operation.\n\n" + "Account: " + requestAccountDisplay(record) + "\n" + "Request ID: " + strings.TrimSpace(record.RequestID) + "\n" + "Status: " + strings.TrimSpace(string(record.Status)) + "\n" + "Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" + "Wait for execution or cancel it.\n\n" + CommandCancel.Slash() } func confirmationPrompt(record *storagemodel.TreasuryRequest) string { if record == nil { return "Request created.\n\n" + CommandConfirm.Slash() + "\n" + CommandCancel.Slash() } title := "Funding request created." if record.OperationType == storagemodel.TreasuryOperationWithdraw { title = "Withdrawal request created." } return title + "\n\n" + "Account: " + requestAccountDisplay(record) + "\n" + "Amount: " + 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 "Welcome to Sendico treasury bot.\n\nAttached account: " + accountCode + " (" + currency + ").\nUse " + CommandFund.Slash() + " to credit your account and " + CommandWithdraw.Slash() + " to debit it.\nAfter entering an amount, use " + CommandConfirm.Slash() + " or " + CommandCancel.Slash() + ".\nUse " + CommandHelp.Slash() + " for detailed usage." } func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string { action := "fund" if operation == storagemodel.TreasuryOperationWithdraw { action = "withdraw" } 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 "Preparing to " + action + " account " + accountCode + " (" + 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" }