528 lines
17 KiB
Go
528 lines
17 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 UserBinding struct {
|
|
TelegramUserID string
|
|
LedgerAccountID string
|
|
AllowedChatIDs []string
|
|
}
|
|
|
|
type UserBindingResolver interface {
|
|
ResolveUserBinding(ctx context.Context, telegramUserID string) (*UserBinding, error)
|
|
}
|
|
|
|
type limitError interface {
|
|
error
|
|
LimitKind() string
|
|
LimitMax() string
|
|
}
|
|
|
|
type Router struct {
|
|
logger mlogger.Logger
|
|
|
|
service TreasuryService
|
|
dialogs *Dialogs
|
|
send SendTextFunc
|
|
tracker ScheduleTracker
|
|
|
|
users UserBindingResolver
|
|
}
|
|
|
|
func NewRouter(
|
|
logger mlogger.Logger,
|
|
service TreasuryService,
|
|
send SendTextFunc,
|
|
tracker ScheduleTracker,
|
|
users UserBindingResolver,
|
|
) *Router {
|
|
if logger != nil {
|
|
logger = logger.Named("treasury_router")
|
|
}
|
|
return &Router{
|
|
logger: logger,
|
|
service: service,
|
|
dialogs: NewDialogs(),
|
|
send: send,
|
|
tracker: tracker,
|
|
users: users,
|
|
}
|
|
}
|
|
|
|
func (r *Router) Enabled() bool {
|
|
return r != nil && r.service != nil && r.users != nil
|
|
}
|
|
|
|
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)),
|
|
)
|
|
}
|
|
|
|
binding, err := r.users.ResolveUserBinding(ctx, userID)
|
|
if err != nil {
|
|
if r.logger != nil {
|
|
r.logger.Warn("Failed to resolve treasury user binding",
|
|
zap.Error(err),
|
|
zap.String("telegram_user_id", userID),
|
|
zap.String("chat_id", chatID))
|
|
}
|
|
_ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check treasury authorization right now. Please try again.")
|
|
return true
|
|
}
|
|
if binding == nil || strings.TrimSpace(binding.LedgerAccountID) == "" {
|
|
r.logUnauthorized(update)
|
|
_ = r.sendText(ctx, chatID, unauthorizedMessage)
|
|
return true
|
|
}
|
|
if !isChatAllowed(chatID, binding.AllowedChatIDs) {
|
|
r.logUnauthorized(update)
|
|
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
|
|
return true
|
|
}
|
|
accountID := strings.TrimSpace(binding.LedgerAccountID)
|
|
|
|
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 isChatAllowed(chatID string, allowedChatIDs []string) bool {
|
|
chatID = strings.TrimSpace(chatID)
|
|
if chatID == "" {
|
|
return false
|
|
}
|
|
if len(allowedChatIDs) == 0 {
|
|
return true
|
|
}
|
|
for _, allowed := range allowedChatIDs {
|
|
if strings.TrimSpace(allowed) == chatID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func formatSeconds(value int64) string {
|
|
if value == 1 {
|
|
return "1 second"
|
|
}
|
|
return strconv.FormatInt(value, 10) + " seconds"
|
|
}
|