371 lines
11 KiB
Go
371 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 welcomeMessage = "Welcome to tgsettle treasury bot.\n\nUse /fund to credit your account and /withdraw to debit it.\nAfter entering an amount, use /confirm or /cancel."
|
|
|
|
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 {
|
|
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 "start":
|
|
_ = r.sendText(ctx, chatID, welcomeMessage)
|
|
return true
|
|
case "fund":
|
|
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund)
|
|
return true
|
|
case "withdraw":
|
|
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw)
|
|
return true
|
|
case "confirm":
|
|
r.confirm(ctx, userID, accountID, chatID)
|
|
return true
|
|
case "cancel":
|
|
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, "Confirm operation?\n\n/confirm\n/cancel")
|
|
return true
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(text, "/") {
|
|
_ = r.sendText(ctx, chatID, "Supported commands:\n/start\n/fund\n/withdraw\n/confirm\n/cancel")
|
|
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 /cancel")
|
|
return
|
|
case "daily":
|
|
_ = r.sendText(ctx, chatID, "Daily amount limit exceeded.\n\nMax per day: "+typed.LimitMax()+"\n\nEnter another amount or /cancel")
|
|
return
|
|
}
|
|
}
|
|
if errors.Is(err, merrors.ErrInvalidArg) {
|
|
_ = r.sendText(ctx, chatID, "Invalid amount.\n\nEnter another amount or /cancel")
|
|
return
|
|
}
|
|
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or /cancel")
|
|
return
|
|
}
|
|
if record == nil {
|
|
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or /cancel")
|
|
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 /cancel or create a new request with /fund or /withdraw.")
|
|
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/cancel"
|
|
}
|
|
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/cancel"
|
|
}
|
|
|
|
func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
|
|
if record == nil {
|
|
return "Request created.\n\n/confirm\n/cancel"
|
|
}
|
|
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" +
|
|
"Confirm operation?\n\n/confirm\n/cancel"
|
|
}
|
|
|
|
func parseCommand(text string) string {
|
|
text = strings.TrimSpace(text)
|
|
if !strings.HasPrefix(text, "/") {
|
|
return ""
|
|
}
|
|
token := text
|
|
if idx := strings.IndexAny(token, " \t\n\r"); idx >= 0 {
|
|
token = token[:idx]
|
|
}
|
|
token = strings.TrimPrefix(token, "/")
|
|
if idx := strings.Index(token, "@"); idx >= 0 {
|
|
token = token[:idx]
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(token))
|
|
}
|
|
|
|
func formatSeconds(value int64) string {
|
|
if value == 1 {
|
|
return "1 second"
|
|
}
|
|
return strconv.FormatInt(value, 10) + " seconds"
|
|
}
|