improved tgsettle messages + storage fixes
This commit is contained in:
@@ -6,6 +6,7 @@ type Command string
|
||||
|
||||
const (
|
||||
CommandStart Command = "start"
|
||||
CommandHelp Command = "help"
|
||||
CommandFund Command = "fund"
|
||||
CommandWithdraw Command = "withdraw"
|
||||
CommandConfirm Command = "confirm"
|
||||
@@ -14,6 +15,7 @@ const (
|
||||
|
||||
var supportedCommands = []Command{
|
||||
CommandStart,
|
||||
CommandHelp,
|
||||
CommandFund,
|
||||
CommandWithdraw,
|
||||
CommandConfirm,
|
||||
@@ -56,3 +58,29 @@ func supportedCommandsMessage() string {
|
||||
func confirmationCommandsMessage() string {
|
||||
return "Confirm operation?\n\n" + CommandConfirm.Slash() + "\n" + CommandCancel.Slash()
|
||||
}
|
||||
|
||||
func helpMessage(accountCode string, currency string) string {
|
||||
accountCode = strings.TrimSpace(accountCode)
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if accountCode == "" {
|
||||
accountCode = "N/A"
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "N/A"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"Treasury bot help",
|
||||
"",
|
||||
"Attached account: " + accountCode + " (" + currency + ")",
|
||||
"",
|
||||
"How to use:",
|
||||
"1) Start funding with " + CommandFund.Slash() + " or withdrawal with " + CommandWithdraw.Slash(),
|
||||
"2) Enter amount as decimal, dot separator, no currency (example: 1250.75)",
|
||||
"3) Confirm with " + CommandConfirm.Slash() + " or abort with " + CommandCancel.Slash(),
|
||||
"",
|
||||
"After confirmation there is a cooldown window. You can cancel during it with " + CommandCancel.Slash() + ".",
|
||||
"You will receive a follow-up message with execution success or failure.",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
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() + "."
|
||||
const amountInputHint = "Enter 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
|
||||
|
||||
@@ -26,6 +26,12 @@ type ScheduleTracker interface {
|
||||
Untrack(requestID string)
|
||||
}
|
||||
|
||||
type AccountProfile struct {
|
||||
AccountID string
|
||||
AccountCode string
|
||||
Currency string
|
||||
}
|
||||
|
||||
type CreateRequestInput struct {
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
TelegramUserID string
|
||||
@@ -39,6 +45,7 @@ type TreasuryService interface {
|
||||
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)
|
||||
@@ -119,6 +126,18 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook
|
||||
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)
|
||||
@@ -134,21 +153,49 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook
|
||||
return true
|
||||
}
|
||||
|
||||
command := parseCommand(text)
|
||||
switch command {
|
||||
case CommandStart:
|
||||
_ = r.sendText(ctx, chatID, welcomeMessage)
|
||||
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
|
||||
}
|
||||
@@ -182,7 +229,10 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook
|
||||
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))
|
||||
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, "Unable to check pending treasury operations right now. Please try again.")
|
||||
return
|
||||
}
|
||||
if active != nil {
|
||||
@@ -199,7 +249,8 @@ func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatI
|
||||
OperationType: operation,
|
||||
LedgerAccountID: accountID,
|
||||
})
|
||||
_ = r.sendText(ctx, chatID, "Enter amount:")
|
||||
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) {
|
||||
@@ -231,7 +282,7 @@ func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID st
|
||||
}
|
||||
}
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
_ = r.sendText(ctx, chatID, "Invalid amount.\n\nEnter another amount or "+CommandCancel.Slash())
|
||||
_ = r.sendText(ctx, chatID, "Invalid amount.\n\n"+amountInputHint+"\n\nEnter another amount or "+CommandCancel.Slash())
|
||||
return
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or "+CommandCancel.Slash())
|
||||
@@ -276,7 +327,7 @@ func (r *Router) confirm(ctx context.Context, userID string, accountID string, c
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "Operation confirmed.\n\nExecution scheduled in "+formatSeconds(delay)+".\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
|
||||
_ = r.sendText(ctx, chatID, "Operation confirmed.\n\nExecution scheduled in "+formatSeconds(delay)+".\nYou can cancel during this cooldown with "+CommandCancel.Slash()+".\n\nYou will receive a follow-up message with execution success or failure.\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
|
||||
}
|
||||
|
||||
func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) {
|
||||
@@ -315,7 +366,16 @@ func (r *Router) sendText(ctx context.Context, chatID string, text string) error
|
||||
if chatID == "" || text == "" {
|
||||
return nil
|
||||
}
|
||||
return r.send(ctx, chatID, text)
|
||||
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) {
|
||||
@@ -337,6 +397,7 @@ func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
|
||||
return "You already have a pending treasury operation.\n\n" + CommandCancel.Slash()
|
||||
}
|
||||
return "You already have a pending treasury operation.\n\n" +
|
||||
"Account: " + requestAccountDisplay(record) + "\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" +
|
||||
@@ -352,11 +413,89 @@ func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
|
||||
title = "Withdrawal request created."
|
||||
}
|
||||
return title + "\n\n" +
|
||||
"Account: " + strings.TrimSpace(record.LedgerAccountID) + "\n" +
|
||||
"Account: " + requestAccountDisplay(record) + "\n" +
|
||||
"Amount: " + 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 "Welcome to Sendico treasury bot.\n\nAttached account: " + accountCode + " (" + currency + ").\nUse " + CommandFund.Slash() + " to credit your account and " + CommandWithdraw.Slash() + " to debit it.\nAfter entering an amount, use " + CommandConfirm.Slash() + " or " + CommandCancel.Slash() + ".\nUse " + CommandHelp.Slash() + " for detailed usage."
|
||||
}
|
||||
|
||||
func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string {
|
||||
action := "fund"
|
||||
if operation == storagemodel.TreasuryOperationWithdraw {
|
||||
action = "withdraw"
|
||||
}
|
||||
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 "Preparing to " + action + " account " + accountCode + " (" + 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"
|
||||
|
||||
@@ -24,6 +24,14 @@ func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storage
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) GetAccountProfile(_ context.Context, ledgerAccountID string) (*AccountProfile, error) {
|
||||
return &AccountProfile{
|
||||
AccountID: ledgerAccountID,
|
||||
AccountCode: ledgerAccountID,
|
||||
Currency: "USD",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fakeService) CreateRequest(context.Context, CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -124,7 +132,11 @@ func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != "Enter amount:" {
|
||||
if sent[0] != amountPromptMessage(
|
||||
storagemodel.TreasuryOperationFund,
|
||||
&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"},
|
||||
"acct-1",
|
||||
) {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
@@ -186,7 +198,38 @@ func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != welcomeMessage {
|
||||
if sent[0] != welcomeMessage(&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"}) {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterHelpAuthorizedShowsHelp(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "123",
|
||||
Text: "/help",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != helpMessage("acct-1", "USD") {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user