Files
sendico/api/gateway/tgsettle/internal/service/treasury/bot/router.go
2026-03-05 11:02:30 +01:00

366 lines
11 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 = "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"
}