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." var welcomeMessage = "Welcome to tgsettle treasury bot.\n\nUse " + CommandFund.Slash() + " to credit your account and " + CommandWithdraw.Slash() + " to debit it.\nAfter entering an amount, use " + CommandConfirm.Slash() + " or " + CommandCancel.Slash() + "." type SendTextFunc func(ctx context.Context, chatID string, text string) error type ScheduleTracker interface { TrackScheduled(record *storagemodel.TreasuryRequest) Untrack(requestID 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) 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 } 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 } command := parseCommand(text) switch command { case CommandStart: _ = r.sendText(ctx, chatID, welcomeMessage) return true case CommandFund: r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund) return true case CommandWithdraw: r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw) return true case CommandConfirm: r.confirm(ctx, userID, accountID, chatID) return true case CommandCancel: 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 { r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID)) 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, }) _ = r.sendText(ctx, chatID, "Enter amount:") } 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\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)+".\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 } return r.send(ctx, chatID, text) } 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" + "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: " + strings.TrimSpace(record.LedgerAccountID) + "\n" + "Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" + confirmationCommandsMessage() } func formatSeconds(value int64) string { if value == 1 { return "1 second" } return strconv.FormatInt(value, 10) + " seconds" }