Files
sendico/api/gateway/tgsettle/internal/service/treasury/bot/router.go
2026-03-05 13:24:41 +01:00

516 lines
16 KiB
Go

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"
}