diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index d3718e7a..21102dee 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -181,38 +181,15 @@ func (s *Service) Shutdown() { } func (s *Service) startTreasuryModule() { - if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil { + if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil || s.repo.TreasuryTelegramUsers() == nil { return } if s.cfg.DiscoveryRegistry == nil { s.logger.Warn("Treasury module disabled: discovery registry is unavailable") return } - configuredUsers := s.cfg.Treasury.Telegram.Users - if len(configuredUsers) == 0 { - return - } - users := make([]treasurysvc.UserBinding, 0, len(configuredUsers)) - configuredUserIDs := make([]string, 0, len(configuredUsers)) - for _, binding := range configuredUsers { - userID := strings.TrimSpace(binding.TelegramUserID) - accountID := strings.TrimSpace(binding.LedgerAccount) - if userID != "" { - configuredUserIDs = append(configuredUserIDs, userID) - } - if userID == "" || accountID == "" { - continue - } - users = append(users, treasurysvc.UserBinding{ - TelegramUserID: userID, - LedgerAccount: accountID, - }) - } - if len(users) == 0 { - s.logger.Warn("Treasury module disabled: no valid treasury.telegram.users bindings", - zap.Int("configured_bindings", len(configuredUsers)), - zap.Strings("configured_user_ids", configuredUserIDs)) - return + if len(s.cfg.Treasury.Telegram.Users) > 0 || len(s.cfg.Treasury.Telegram.AllowedChats) > 0 { + s.logger.Warn("Treasury telegram config users/chats are ignored; use treasury_telegram_users collection for runtime authorization") } ledgerTimeout := s.cfg.Treasury.Ledger.Timeout @@ -241,10 +218,9 @@ func (s *Service) startTreasuryModule() { module, err := treasurysvc.NewModule( s.logger, s.repo.TreasuryRequests(), + s.repo.TreasuryTelegramUsers(), ledgerClient, treasurysvc.Config{ - AllowedChats: s.cfg.Treasury.Telegram.AllowedChats, - Users: users, ExecutionDelay: executionDelay, PollInterval: pollInterval, MaxAmountPerOperation: s.cfg.Treasury.Limits.MaxAmountPerOperation, diff --git a/api/gateway/tgsettle/internal/service/gateway/service_test.go b/api/gateway/tgsettle/internal/service/gateway/service_test.go index 1ae84c4a..bf509dd9 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service_test.go +++ b/api/gateway/tgsettle/internal/service/gateway/service_test.go @@ -81,6 +81,7 @@ type fakeRepo struct { tg *fakeTelegramStore pending *fakePendingStore treasury storage.TreasuryRequestsStore + users storage.TreasuryTelegramUsersStore } func (f *fakeRepo) Payments() storage.PaymentsStore { @@ -99,6 +100,10 @@ func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore { return f.treasury } +func (f *fakeRepo) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore { + return f.users +} + type fakePendingStore struct { mu sync.Mutex records map[string]*storagemodel.PendingConfirmation diff --git a/api/gateway/tgsettle/internal/service/treasury/bot/router.go b/api/gateway/tgsettle/internal/service/treasury/bot/router.go index 23d8186f..728af287 100644 --- a/api/gateway/tgsettle/internal/service/treasury/bot/router.go +++ b/api/gateway/tgsettle/internal/service/treasury/bot/router.go @@ -51,6 +51,16 @@ type TreasuryService interface { 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 @@ -65,9 +75,7 @@ type Router struct { send SendTextFunc tracker ScheduleTracker - allowedChats map[string]struct{} - userAccounts map[string]string - allowAnyChat bool + users UserBindingResolver } func NewRouter( @@ -75,43 +83,23 @@ func NewRouter( service TreasuryService, send SendTextFunc, tracker ScheduleTracker, - allowedChats []string, - userAccounts map[string]string, + users UserBindingResolver, ) *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, + logger: logger, + service: service, + dialogs: NewDialogs(), + send: send, + tracker: tracker, + users: users, } } func (r *Router) Enabled() bool { - return r != nil && r.service != nil && len(r.userAccounts) > 0 + return r != nil && r.service != nil && r.users != nil } func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool { @@ -138,20 +126,28 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook ) } - if !r.allowAnyChat { - if _, ok := r.allowedChats[chatID]; !ok { - r.logUnauthorized(update) - _ = r.sendText(ctx, chatID, unauthorizedChatMessage) - return true + 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 } - - accountID, ok := r.userAccounts[userID] - if !ok || strings.TrimSpace(accountID) == "" { + 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: @@ -507,6 +503,22 @@ func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID stri 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" diff --git a/api/gateway/tgsettle/internal/service/treasury/bot/router_test.go b/api/gateway/tgsettle/internal/service/treasury/bot/router_test.go index d685f525..b4b4bfb3 100644 --- a/api/gateway/tgsettle/internal/service/treasury/bot/router_test.go +++ b/api/gateway/tgsettle/internal/service/treasury/bot/router_test.go @@ -12,6 +12,21 @@ import ( type fakeService struct{} +type fakeUserBindingResolver struct { + bindings map[string]*UserBinding + err error +} + +func (f fakeUserBindingResolver) ResolveUserBinding(_ context.Context, telegramUserID string) (*UserBinding, error) { + if f.err != nil { + return nil, f.err + } + if f.bindings == nil { + return nil, nil + } + return f.bindings[telegramUserID], nil +} + func (fakeService) ExecutionDelay() time.Duration { return 30 * time.Second } @@ -54,8 +69,15 @@ func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) { return nil }, nil, - []string{"100"}, - map[string]string{"123": "acct-1"}, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + AllowedChatIDs: []string{"100"}, + }, + }, + }, ) handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ Message: &model.TelegramMessage{ @@ -85,8 +107,15 @@ func TestRouterUnknownChatGetsDenied(t *testing.T) { return nil }, nil, - []string{"100"}, - map[string]string{"123": "acct-1"}, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + AllowedChatIDs: []string{"100"}, + }, + }, + }, ) handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ Message: &model.TelegramMessage{ @@ -116,8 +145,14 @@ func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) { return nil }, nil, - nil, - map[string]string{"123": "acct-1"}, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, ) handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ Message: &model.TelegramMessage{ @@ -151,8 +186,14 @@ func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) { return nil }, nil, - nil, - map[string]string{"123": "acct-1"}, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, ) handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ Message: &model.TelegramMessage{ @@ -182,8 +223,14 @@ func TestRouterStartAuthorizedShowsWelcome(t *testing.T) { return nil }, nil, - nil, - map[string]string{"123": "acct-1"}, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, ) handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ Message: &model.TelegramMessage{ @@ -213,8 +260,14 @@ func TestRouterHelpAuthorizedShowsHelp(t *testing.T) { return nil }, nil, - nil, - map[string]string{"123": "acct-1"}, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, ) handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ Message: &model.TelegramMessage{ @@ -244,8 +297,14 @@ func TestRouterStartUnauthorizedGetsDenied(t *testing.T) { return nil }, nil, - nil, - map[string]string{"123": "acct-1"}, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, ) handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ Message: &model.TelegramMessage{ @@ -275,8 +334,14 @@ func TestRouterPlainTextWithoutSession_ShowsSupportedCommands(t *testing.T) { return nil }, nil, - nil, - map[string]string{"123": "acct-1"}, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, ) handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ Message: &model.TelegramMessage{ diff --git a/api/gateway/tgsettle/internal/service/treasury/config.go b/api/gateway/tgsettle/internal/service/treasury/config.go index 8b3208f0..6ea1cf15 100644 --- a/api/gateway/tgsettle/internal/service/treasury/config.go +++ b/api/gateway/tgsettle/internal/service/treasury/config.go @@ -2,15 +2,7 @@ package treasury import "time" -type UserBinding struct { - TelegramUserID string - LedgerAccount string -} - type Config struct { - AllowedChats []string - Users []UserBinding - ExecutionDelay time.Duration PollInterval time.Duration diff --git a/api/gateway/tgsettle/internal/service/treasury/module.go b/api/gateway/tgsettle/internal/service/treasury/module.go index 7fa5669a..d5cfdd5a 100644 --- a/api/gateway/tgsettle/internal/service/treasury/module.go +++ b/api/gateway/tgsettle/internal/service/treasury/module.go @@ -26,6 +26,7 @@ type Module struct { func NewModule( logger mlogger.Logger, repo storage.TreasuryRequestsStore, + users storage.TreasuryTelegramUsersStore, ledgerClient ledger.Client, cfg Config, send bot.SendTextFunc, @@ -33,6 +34,9 @@ func NewModule( if logger != nil { logger = logger.Named("treasury") } + if users == nil { + return nil, merrors.InvalidArgument("treasury telegram users store is required", "users") + } service, err := NewService( logger, repo, @@ -45,23 +49,13 @@ func NewModule( return nil, err } - users := map[string]string{} - for _, binding := range cfg.Users { - userID := strings.TrimSpace(binding.TelegramUserID) - accountID := strings.TrimSpace(binding.LedgerAccount) - if userID == "" || accountID == "" { - continue - } - users[userID] = accountID - } - module := &Module{ logger: logger, service: service, ledger: ledgerClient, } module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval) - module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, cfg.AllowedChats, users) + module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, &botUsersAdapter{store: users}) return module, nil } @@ -99,6 +93,28 @@ type botServiceAdapter struct { svc *Service } +type botUsersAdapter struct { + store storage.TreasuryTelegramUsersStore +} + +func (a *botUsersAdapter) ResolveUserBinding(ctx context.Context, telegramUserID string) (*bot.UserBinding, error) { + if a == nil || a.store == nil { + return nil, merrors.Internal("treasury users store unavailable") + } + record, err := a.store.FindByTelegramUserID(ctx, telegramUserID) + if err != nil { + return nil, err + } + if record == nil { + return nil, nil + } + return &bot.UserBinding{ + TelegramUserID: strings.TrimSpace(record.TelegramUserID), + LedgerAccountID: strings.TrimSpace(record.LedgerAccountID), + AllowedChatIDs: normalizeChatIDs(record.AllowedChatIDs), + }, nil +} + func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) { if a == nil || a.svc == nil { return 0 @@ -164,3 +180,26 @@ func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string, } return a.svc.CancelRequest(ctx, requestID, telegramUserID) } + +func normalizeChatIDs(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, next := range values { + next = strings.TrimSpace(next) + if next == "" { + continue + } + if _, ok := seen[next]; ok { + continue + } + seen[next] = struct{}{} + out = append(out, next) + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/api/gateway/tgsettle/storage/model/storable.go b/api/gateway/tgsettle/storage/model/storable.go index 14b46044..aa6fb054 100644 --- a/api/gateway/tgsettle/storage/model/storable.go +++ b/api/gateway/tgsettle/storage/model/storable.go @@ -5,6 +5,7 @@ const ( telegramConfirmationsCollection = "telegram_confirmations" pendingConfirmationsCollection = "pending_confirmations" treasuryRequestsCollection = "treasury_requests" + treasuryTelegramUsersCollection = "treasury_telegram_users" ) func (*PaymentRecord) Collection() string { @@ -22,3 +23,7 @@ func (*PendingConfirmation) Collection() string { func (*TreasuryRequest) Collection() string { return treasuryRequestsCollection } + +func (*TreasuryTelegramUser) Collection() string { + return treasuryTelegramUsersCollection +} diff --git a/api/gateway/tgsettle/storage/model/treasury.go b/api/gateway/tgsettle/storage/model/treasury.go index 37740cb9..2496e550 100644 --- a/api/gateway/tgsettle/storage/model/treasury.go +++ b/api/gateway/tgsettle/storage/model/treasury.go @@ -49,3 +49,11 @@ type TreasuryRequest struct { Active bool `bson:"active,omitempty" json:"active,omitempty"` } + +type TreasuryTelegramUser struct { + storable.Base `bson:",inline" json:",inline"` + + TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"` + LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"` + AllowedChatIDs []string `bson:"allowedChatIds,omitempty" json:"allowed_chat_ids,omitempty"` +} diff --git a/api/gateway/tgsettle/storage/mongo/repository.go b/api/gateway/tgsettle/storage/mongo/repository.go index 146411d4..e0e5b828 100644 --- a/api/gateway/tgsettle/storage/mongo/repository.go +++ b/api/gateway/tgsettle/storage/mongo/repository.go @@ -25,6 +25,7 @@ type Repository struct { tg storage.TelegramConfirmationsStore pending storage.PendingConfirmationsStore treasury storage.TreasuryRequestsStore + users storage.TreasuryTelegramUsersStore outbox gatewayoutbox.Store } @@ -80,6 +81,11 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { result.logger.Error("Failed to initialise treasury requests store", zap.Error(err), zap.String("store", "treasury_requests")) return nil, err } + treasuryUsersStore, err := store.NewTreasuryTelegramUsers(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise treasury telegram users store", zap.Error(err), zap.String("store", "treasury_telegram_users")) + return nil, err + } outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db) if err != nil { result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox")) @@ -89,6 +95,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { result.tg = tgStore result.pending = pendingStore result.treasury = treasuryStore + result.users = treasuryUsersStore result.outbox = outboxStore result.logger.Info("Payment gateway MongoDB storage initialised") return result, nil @@ -110,6 +117,10 @@ func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore { return r.treasury } +func (r *Repository) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore { + return r.users +} + func (r *Repository) Outbox() gatewayoutbox.Store { return r.outbox } diff --git a/api/gateway/tgsettle/storage/mongo/store/treasury_telegram_users.go b/api/gateway/tgsettle/storage/mongo/store/treasury_telegram_users.go new file mode 100644 index 00000000..04c4e597 --- /dev/null +++ b/api/gateway/tgsettle/storage/mongo/store/treasury_telegram_users.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/gateway/tgsettle/storage" + "github.com/tech/sendico/gateway/tgsettle/storage/model" + "github.com/tech/sendico/pkg/db/repository" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +const ( + treasuryTelegramUsersCollection = "treasury_telegram_users" + fieldTreasuryTelegramUserID = "telegramUserId" +) + +type TreasuryTelegramUsers struct { + logger mlogger.Logger + repo repository.Repository +} + +func NewTreasuryTelegramUsers(logger mlogger.Logger, db *mongo.Database) (*TreasuryTelegramUsers, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("treasury_telegram_users").With(zap.String("collection", treasuryTelegramUsersCollection)) + + repo := repository.CreateMongoRepository(db, treasuryTelegramUsersCollection) + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldTreasuryTelegramUserID, Sort: ri.Asc}}, + Unique: true, + }); err != nil { + logger.Error("Failed to create treasury telegram users user_id index", zap.Error(err), zap.String("index_field", fieldTreasuryTelegramUserID)) + return nil, err + } + + return &TreasuryTelegramUsers{ + logger: logger, + repo: repo, + }, nil +} + +func (t *TreasuryTelegramUsers) FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error) { + telegramUserID = strings.TrimSpace(telegramUserID) + if telegramUserID == "" { + return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id") + } + var result model.TreasuryTelegramUser + err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryTelegramUserID, telegramUserID), &result) + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.logger.Warn("Failed to load treasury telegram user", zap.Error(err), zap.String("telegram_user_id", telegramUserID)) + } + return nil, err + } + result.TelegramUserID = strings.TrimSpace(result.TelegramUserID) + result.LedgerAccountID = strings.TrimSpace(result.LedgerAccountID) + if len(result.AllowedChatIDs) > 0 { + normalized := make([]string, 0, len(result.AllowedChatIDs)) + for _, next := range result.AllowedChatIDs { + next = strings.TrimSpace(next) + if next == "" { + continue + } + normalized = append(normalized, next) + } + result.AllowedChatIDs = normalized + } + if result.TelegramUserID == "" || result.LedgerAccountID == "" { + return nil, nil + } + return &result, nil +} + +var _ storage.TreasuryTelegramUsersStore = (*TreasuryTelegramUsers)(nil) diff --git a/api/gateway/tgsettle/storage/storage.go b/api/gateway/tgsettle/storage/storage.go index 1cd2138d..4f582a72 100644 --- a/api/gateway/tgsettle/storage/storage.go +++ b/api/gateway/tgsettle/storage/storage.go @@ -15,6 +15,7 @@ type Repository interface { TelegramConfirmations() TelegramConfirmationsStore PendingConfirmations() PendingConfirmationsStore TreasuryRequests() TreasuryRequestsStore + TreasuryTelegramUsers() TreasuryTelegramUsersStore } type PaymentsStore interface { @@ -46,3 +47,7 @@ type TreasuryRequestsStore interface { Update(ctx context.Context, record *model.TreasuryRequest) error ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]model.TreasuryRequest, error) } + +type TreasuryTelegramUsersStore interface { + FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error) +}