From d06ec80614209176b2da562b0c53341895ed4545 Mon Sep 17 00:00:00 2001 From: Danya H Date: Fri, 6 Nov 2026 18:14:19 +0000 Subject: [PATCH 1/9] feat(config,database): scaffold config + db --- .env.example | 7 ++-- cmd/bot/main.go | 58 +++++++++++++++++++++++++++++++++- internal/config/config.go | 40 +++++++++++++++++++++++ internal/config/config_test.go | 44 ++++++++++++++++++++++++++ internal/database/db.go | 34 ++++++++++++++++++++ 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/database/db.go diff --git a/.env.example b/.env.example index e7d81f0..da7c409 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ TELEGRAM_BOT_TOKEN= -POSTGRES_USER= -POSTGRES_PASSWORD= -POSTGRES_DB= +DATABASE_URL=postgres://pllist:pllist@localhost:5432/pllist?sslmode=disable +POSTGRES_USER=pllist +POSTGRES_PASSWORD=pllist +POSTGRES_DB=pllist diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 38dd16d..06c4036 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -1,3 +1,59 @@ package main -func main() {} +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/mymmrac/telego" + + "git.entityseven.com/rilaxik/pllist/internal/config" + "git.entityseven.com/rilaxik/pllist/internal/database" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + db, err := database.Open(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("get sql database: %w", err) + } + defer sqlDB.Close() + + bot, err := telego.NewBot(cfg.TelegramBotToken) + if err != nil { + return fmt.Errorf("create bot: %w", err) + } + + updates, err := bot.UpdatesViaLongPolling(ctx, nil) + if err != nil { + return fmt.Errorf("start long polling: %w", err) + } + + log.Println("bot started") + for update := range updates { + log.Printf("update %d ignored", update.UpdateID) + } + log.Println("bot stopped") + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..93248a7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,40 @@ +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + TelegramBotToken string + DatabaseURL string +} + +func Load() (Config, error) { + if err := godotenv.Load(); err != nil && !errors.Is(err, fs.ErrNotExist) { + return Config{}, fmt.Errorf("load .env: %w", err) + } + + cfg := Config{ + TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"), + DatabaseURL: os.Getenv("DATABASE_URL"), + } + if err := cfg.validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func (c Config) validate() error { + if c.TelegramBotToken == "" { + return errors.New("TELEGRAM_BOT_TOKEN is required") + } + if c.DatabaseURL == "" { + return errors.New("DATABASE_URL is required") + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5e5321e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidate(t *testing.T) { + tests := []struct { + name string + cfg Config + wantErr string + }{ + { + name: "valid", + cfg: Config{TelegramBotToken: "token", DatabaseURL: "postgres://db"}, + }, + { + name: "missing token", + cfg: Config{DatabaseURL: "postgres://db"}, + wantErr: "TELEGRAM_BOT_TOKEN", + }, + { + name: "missing database url", + cfg: Config{TelegramBotToken: "token"}, + wantErr: "DATABASE_URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validate() error = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("validate() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..8e20db6 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,34 @@ +package database + +import ( + "context" + "fmt" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func Open(ctx context.Context, databaseURL string) (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("get sql database: %w", err) + } + sqlDB.SetMaxOpenConns(20) + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(30 * time.Minute) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := sqlDB.PingContext(pingCtx); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + return db, nil +} -- 2.54.0 From a283a9c454d63640261163c09d8d890b064539c3 Mon Sep 17 00:00:00 2001 From: Danya H Date: Sat, 7 Nov 2026 01:35:12 +0000 Subject: [PATCH 2/9] feat: roadmap M1 --- .env.example | 1 + cmd/bot/main.go | 31 +++++- docs/architecture.md | 17 ++- docs/infra.md | 1 + docs/roadmap-mvp.md | 4 +- internal/config/config.go | 10 ++ internal/config/config_test.go | 34 ++++++ internal/domain/errors.go | 10 ++ internal/domain/list.go | 43 ++++++++ internal/domain/user.go | 10 ++ internal/repository/list.go | 105 +++++++++++++++++++ internal/repository/models.go | 42 ++++++++ internal/repository/user.go | 35 +++++++ internal/service/access.go | 53 ++++++++++ internal/service/access_test.go | 69 ++++++++++++ internal/service/list.go | 71 +++++++++++++ internal/service/list_test.go | 33 ++++++ internal/service/session.go | 76 ++++++++++++++ internal/service/session_test.go | 53 ++++++++++ internal/service/user.go | 23 ++++ internal/telegram/bot.go | 86 +++++++++++++++ internal/telegram/handlers.go | 43 ++++++++ internal/telegram/list/callback_data.go | 8 ++ internal/telegram/list/callback_data_test.go | 20 ++++ internal/telegram/list/callbacks.go | 64 +++++++++++ internal/telegram/list/commands.go | 22 ++++ internal/telegram/list/handler.go | 45 ++++++++ internal/telegram/list/keyboards.go | 47 +++++++++ internal/telegram/list/pending.go | 35 +++++++ internal/telegram/list/text.go | 37 +++++++ internal/telegram/list/views.go | 23 ++++ internal/telegram/locker.go | 39 +++++++ internal/telegram/menu/handler.go | 55 ++++++++++ internal/telegram/menu/keyboards.go | 24 +++++ internal/telegram/menu/text.go | 16 +++ internal/telegram/messages.go | 9 ++ internal/telegram/middleware.go | 68 ++++++++++++ internal/telegram/middleware_test.go | 50 +++++++++ internal/telegram/routes.go | 14 +++ internal/telegram/shared/callback.go | 46 ++++++++ internal/telegram/shared/callback_test.go | 40 +++++++ internal/telegram/shared/pending.go | 38 +++++++ internal/telegram/shared/pending_test.go | 42 ++++++++ internal/telegram/shared/sender.go | 52 +++++++++ internal/telegram/shared/text.go | 22 ++++ internal/telegram/shared/ui.go | 27 +++++ internal/telegram/shared/user.go | 18 ++++ internal/telegram/util.go | 33 ++++++ 48 files changed, 1737 insertions(+), 7 deletions(-) create mode 100644 internal/domain/errors.go create mode 100644 internal/domain/list.go create mode 100644 internal/domain/user.go create mode 100644 internal/repository/list.go create mode 100644 internal/repository/models.go create mode 100644 internal/repository/user.go create mode 100644 internal/service/access.go create mode 100644 internal/service/access_test.go create mode 100644 internal/service/list.go create mode 100644 internal/service/list_test.go create mode 100644 internal/service/session.go create mode 100644 internal/service/session_test.go create mode 100644 internal/service/user.go create mode 100644 internal/telegram/bot.go create mode 100644 internal/telegram/handlers.go create mode 100644 internal/telegram/list/callback_data.go create mode 100644 internal/telegram/list/callback_data_test.go create mode 100644 internal/telegram/list/callbacks.go create mode 100644 internal/telegram/list/commands.go create mode 100644 internal/telegram/list/handler.go create mode 100644 internal/telegram/list/keyboards.go create mode 100644 internal/telegram/list/pending.go create mode 100644 internal/telegram/list/text.go create mode 100644 internal/telegram/list/views.go create mode 100644 internal/telegram/locker.go create mode 100644 internal/telegram/menu/handler.go create mode 100644 internal/telegram/menu/keyboards.go create mode 100644 internal/telegram/menu/text.go create mode 100644 internal/telegram/messages.go create mode 100644 internal/telegram/middleware.go create mode 100644 internal/telegram/middleware_test.go create mode 100644 internal/telegram/routes.go create mode 100644 internal/telegram/shared/callback.go create mode 100644 internal/telegram/shared/callback_test.go create mode 100644 internal/telegram/shared/pending.go create mode 100644 internal/telegram/shared/pending_test.go create mode 100644 internal/telegram/shared/sender.go create mode 100644 internal/telegram/shared/text.go create mode 100644 internal/telegram/shared/ui.go create mode 100644 internal/telegram/shared/user.go create mode 100644 internal/telegram/util.go diff --git a/.env.example b/.env.example index da7c409..1e9ffc4 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ TELEGRAM_BOT_TOKEN= DATABASE_URL=postgres://pllist:pllist@localhost:5432/pllist?sslmode=disable +AUTO_MIGRATE=true POSTGRES_USER=pllist POSTGRES_PASSWORD=pllist POSTGRES_DB=pllist diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 06c4036..acf3bd4 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -7,13 +7,19 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/mymmrac/telego" "git.entityseven.com/rilaxik/pllist/internal/config" "git.entityseven.com/rilaxik/pllist/internal/database" + "git.entityseven.com/rilaxik/pllist/internal/repository" + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram" ) +const pendingTTL = 5 * time.Minute + func main() { if err := run(); err != nil { log.Fatal(err) @@ -39,19 +45,36 @@ func run() error { } defer sqlDB.Close() - bot, err := telego.NewBot(cfg.TelegramBotToken) + if cfg.AutoMigrate { + if err := repository.Migrate(db); err != nil { + return fmt.Errorf("migrate database: %w", err) + } + } + + tgBot, err := telego.NewBot(cfg.TelegramBotToken) if err != nil { return fmt.Errorf("create bot: %w", err) } - updates, err := bot.UpdatesViaLongPolling(ctx, nil) + users := service.NewUserService(repository.NewUserRepository(db)) + listRepo := repository.NewListRepository(db) + access := service.NewAccessService(listRepo) + lists := service.NewListService(listRepo, access) + pending := service.NewPendingStore(pendingTTL) + + updates, err := tgBot.UpdatesViaLongPolling(ctx, nil) if err != nil { return fmt.Errorf("start long polling: %w", err) } + bot, err := telegram.New(tgBot, updates, users, lists, access, pending) + if err != nil { + return err + } + log.Println("bot started") - for update := range updates { - log.Printf("update %d ignored", update.UpdateID) + if err := bot.Run(); err != nil { + return err } log.Println("bot stopped") diff --git a/docs/architecture.md b/docs/architecture.md index 08d4106..bae9b38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,9 +43,24 @@ docs/ architecture, infrastructure, plan, notes | `service` | Use cases and authorization. Owns transactions. Declares repository interfaces. | | `repository` | GORM models and queries. Implements service interfaces. | | `database` | Opens the pool, sets limits, exposes migration entry point. | -| `telegram` | Maps updates to service calls, renders views, maps errors to text. | +| `telegram` | Routes and middleware; feature subpackages own commands, callbacks, and views. | | `events` | `Publisher` seam for future notifications and activity feeds. | +## Telegram layout + +```text +telegram/ + bot.go, routes.go, middleware.go lifecycle, routing, identity, per-user ordering + shared/ sender, user context, nav callbacks, pending text router + menu/ /start, /help, /id, /cancel + list/ list screens, commands, callbacks, prompts +``` + +Features register their own `telegohandler` routes. Cross-feature navigation +callbacks live in `shared`. Free-text prompts are dispatched through +`shared.PendingRouter`, so the root text handler does not grow with each +feature. `item/` and `share/` get their own packages when M2 and M3 land. + ## Layering ```text diff --git a/docs/infra.md b/docs/infra.md index dda3ec8..232ad8d 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -41,6 +41,7 @@ Dokploy injects these into the bot container: | --- | --- | --- | | `TELEGRAM_BOT_TOKEN` | yes | Bot token from BotFather | | `DATABASE_URL` | yes | `postgres://user:pass@host:5432/db?sslmode=disable` | +| `AUTO_MIGRATE` | no | Run GORM AutoMigrate at startup; default `true` | | `POSTGRES_USER` | compose | Database role for local Compose | | `POSTGRES_PASSWORD` | compose | Database password for local Compose | | `POSTGRES_DB` | compose | Database name for local Compose | diff --git a/docs/roadmap-mvp.md b/docs/roadmap-mvp.md index bdb14e9..47ecf06 100644 --- a/docs/roadmap-mvp.md +++ b/docs/roadmap-mvp.md @@ -11,7 +11,7 @@ ## M1 — Users and lists -- Register or refresh a user on `/start`. +- Ensure a user row on every update; store only the Telegram ID. - Commands: `/start`, `/help`, `/lists`, `/new`, `/cancel`, `/id`. - Create, rename, and soft-delete a list. Owner only for rename and delete. - List index with owned and shared lists. @@ -33,7 +33,7 @@ ## M4 — Polish -- Callback-data codec with strict validation. +- Versioned callback-data codec with strict validation. - Confirmation step for destructive actions. - HTML escaping helper and Telegram length limits. - Error mapping from domain errors to user messages. diff --git a/internal/config/config.go b/internal/config/config.go index 93248a7..d049573 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "os" + "strconv" "github.com/joho/godotenv" ) @@ -12,6 +13,7 @@ import ( type Config struct { TelegramBotToken string DatabaseURL string + AutoMigrate bool } func Load() (Config, error) { @@ -22,6 +24,14 @@ func Load() (Config, error) { cfg := Config{ TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"), DatabaseURL: os.Getenv("DATABASE_URL"), + AutoMigrate: true, + } + if value := os.Getenv("AUTO_MIGRATE"); value != "" { + autoMigrate, err := strconv.ParseBool(value) + if err != nil { + return Config{}, fmt.Errorf("AUTO_MIGRATE must be true or false: %w", err) + } + cfg.AutoMigrate = autoMigrate } if err := cfg.validate(); err != nil { return Config{}, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5e5321e..05225f8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -42,3 +42,37 @@ func TestValidate(t *testing.T) { }) } } + +func TestLoadAutoMigrate(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("TELEGRAM_BOT_TOKEN", "token") + t.Setenv("DATABASE_URL", "postgres://db") + + t.Run("default true", func(t *testing.T) { + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !cfg.AutoMigrate { + t.Fatal("AutoMigrate = false, want true") + } + }) + + t.Run("explicit false", func(t *testing.T) { + t.Setenv("AUTO_MIGRATE", "false") + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.AutoMigrate { + t.Fatal("AutoMigrate = true, want false") + } + }) + + t.Run("invalid", func(t *testing.T) { + t.Setenv("AUTO_MIGRATE", "sometimes") + if _, err := Load(); err == nil { + t.Fatal("Load() error = nil, want error") + } + }) +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..972a612 --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,10 @@ +package domain + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrForbidden = errors.New("forbidden") + ErrConflict = errors.New("conflict") + ErrInvalidInput = errors.New("invalid input") +) diff --git a/internal/domain/list.go b/internal/domain/list.go new file mode 100644 index 0000000..d6f51c8 --- /dev/null +++ b/internal/domain/list.go @@ -0,0 +1,43 @@ +package domain + +import "time" + +type Role string + +const ( + RoleOwner Role = "owner" + RoleEditor Role = "editor" + RoleReader Role = "reader" +) + +type Action int + +const ( + ActionViewList Action = iota + ActionEditListMeta + ActionDeleteList +) + +type List struct { + ID int64 + OwnerID int64 + Title string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Access struct { + List List + Role Role +} + +func Allowed(role Role, action Action) bool { + switch action { + case ActionViewList: + return role == RoleOwner || role == RoleEditor || role == RoleReader + case ActionEditListMeta, ActionDeleteList: + return role == RoleOwner + default: + return false + } +} diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..3789602 --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,10 @@ +package domain + +import "time" + +type User struct { + ID int64 + TelegramID int64 + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/repository/list.go b/internal/repository/list.go new file mode 100644 index 0000000..8e9ceb8 --- /dev/null +++ b/internal/repository/list.go @@ -0,0 +1,105 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "gorm.io/gorm" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type ListRepository struct { + db *gorm.DB +} + +func NewListRepository(db *gorm.DB) *ListRepository { + return &ListRepository{db: db} +} + +func (r *ListRepository) Create(ctx context.Context, ownerID int64, title string) (domain.List, error) { + model := ListModel{OwnerID: ownerID, Title: title} + if err := r.db.WithContext(ctx).Create(&model).Error; err != nil { + return domain.List{}, fmt.Errorf("create list: %w", err) + } + return listToDomain(model), nil +} + +func (r *ListRepository) Get(ctx context.Context, id int64) (domain.List, error) { + var model ListModel + err := r.db.WithContext(ctx).First(&model, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return domain.List{}, domain.ErrNotFound + } + if err != nil { + return domain.List{}, fmt.Errorf("get list: %w", err) + } + return listToDomain(model), nil +} + +func (r *ListRepository) ListForUser(ctx context.Context, userID int64) ([]domain.List, error) { + shared := r.db.Model(&ListShareModel{}). + Select("list_id"). + Where("user_id = ?", userID) + + var models []ListModel + err := r.db.WithContext(ctx). + Where("owner_id = ? OR id IN (?)", userID, shared). + Order("updated_at DESC"). + Find(&models). + Error + if err != nil { + return nil, fmt.Errorf("list lists for user: %w", err) + } + + lists := make([]domain.List, len(models)) + for i, model := range models { + lists[i] = listToDomain(model) + } + return lists, nil +} + +func (r *ListRepository) Rename(ctx context.Context, id int64, title string) error { + err := r.db.WithContext(ctx). + Model(&ListModel{}). + Where("id = ?", id). + Update("title", title). + Error + if err != nil { + return fmt.Errorf("rename list: %w", err) + } + return nil +} + +func (r *ListRepository) Delete(ctx context.Context, id int64) error { + if err := r.db.WithContext(ctx).Delete(&ListModel{}, id).Error; err != nil { + return fmt.Errorf("delete list: %w", err) + } + return nil +} + +func (r *ListRepository) Role(ctx context.Context, listID, userID int64) (domain.Role, bool, error) { + var model ListShareModel + err := r.db.WithContext(ctx). + Where("list_id = ? AND user_id = ?", listID, userID). + First(&model). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("get list role: %w", err) + } + return domain.Role(model.Role), true, nil +} + +func listToDomain(model ListModel) domain.List { + return domain.List{ + ID: model.ID, + OwnerID: model.OwnerID, + Title: model.Title, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} diff --git a/internal/repository/models.go b/internal/repository/models.go new file mode 100644 index 0000000..cae8659 --- /dev/null +++ b/internal/repository/models.go @@ -0,0 +1,42 @@ +package repository + +import ( + "time" + + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) error { + return db.AutoMigrate(&UserModel{}, &ListModel{}, &ListShareModel{}) +} + +type UserModel struct { + ID int64 `gorm:"primaryKey"` + TelegramID int64 `gorm:"not null;uniqueIndex"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (UserModel) TableName() string { return "users" } + +type ListModel struct { + ID int64 `gorm:"primaryKey"` + OwnerID int64 `gorm:"not null;index"` + Title string `gorm:"not null;size:120"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (ListModel) TableName() string { return "lists" } + +type ListShareModel struct { + ID int64 `gorm:"primaryKey"` + ListID int64 `gorm:"not null;uniqueIndex:idx_list_user"` + UserID int64 `gorm:"not null;uniqueIndex:idx_list_user"` + Role string `gorm:"not null;size:16"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (ListShareModel) TableName() string { return "list_shares" } diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..f0d2d9b --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + "fmt" + + "gorm.io/gorm" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Ensure(ctx context.Context, telegramID int64) (domain.User, error) { + model := UserModel{TelegramID: telegramID} + err := r.db.WithContext(ctx). + Where("telegram_id = ?", telegramID). + FirstOrCreate(&model). + Error + if err != nil { + return domain.User{}, fmt.Errorf("ensure user: %w", err) + } + return domain.User{ + ID: model.ID, + TelegramID: model.TelegramID, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + }, nil +} diff --git a/internal/service/access.go b/internal/service/access.go new file mode 100644 index 0000000..a7f48f3 --- /dev/null +++ b/internal/service/access.go @@ -0,0 +1,53 @@ +package service + +import ( + "context" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type accessRepository interface { + Get(ctx context.Context, id int64) (domain.List, error) + Role(ctx context.Context, listID, userID int64) (domain.Role, bool, error) +} + +type AccessService struct { + lists accessRepository +} + +func NewAccessService(lists accessRepository) *AccessService { + return &AccessService{lists: lists} +} + +func (s *AccessService) Authorize( + ctx context.Context, userID, listID int64, action domain.Action, +) (domain.Access, error) { + list, err := s.lists.Get(ctx, listID) + if err != nil { + return domain.Access{}, err + } + + role := domain.RoleOwner + if list.OwnerID != userID { + role, err = s.role(ctx, listID, userID) + if err != nil { + return domain.Access{}, err + } + } + + if !domain.Allowed(role, action) { + return domain.Access{}, domain.ErrForbidden + } + return domain.Access{List: list, Role: role}, nil +} + +func (s *AccessService) role(ctx context.Context, listID, userID int64) (domain.Role, error) { + role, ok, err := s.lists.Role(ctx, listID, userID) + if err != nil { + return "", err + } + if !ok { + return "", domain.ErrNotFound + } + return role, nil +} diff --git a/internal/service/access_test.go b/internal/service/access_test.go new file mode 100644 index 0000000..b96d216 --- /dev/null +++ b/internal/service/access_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "errors" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type fakeAccessRepository struct { + lists map[int64]domain.List + roles map[[2]int64]domain.Role +} + +func (f *fakeAccessRepository) Get(_ context.Context, id int64) (domain.List, error) { + list, ok := f.lists[id] + if !ok { + return domain.List{}, domain.ErrNotFound + } + return list, nil +} + +func (f *fakeAccessRepository) Role(_ context.Context, listID, userID int64) (domain.Role, bool, error) { + role, ok := f.roles[[2]int64{listID, userID}] + return role, ok, nil +} + +func TestAuthorize(t *testing.T) { + const listID = int64(10) + repo := &fakeAccessRepository{ + lists: map[int64]domain.List{ + listID: {ID: listID, OwnerID: 1, Title: "Groceries"}, + }, + roles: map[[2]int64]domain.Role{ + {listID, 2}: domain.RoleEditor, + {listID, 3}: domain.RoleReader, + }, + } + access := NewAccessService(repo) + + tests := []struct { + name string + userID int64 + listID int64 + action domain.Action + wantErr error + }{ + {name: "owner views", userID: 1, listID: listID, action: domain.ActionViewList}, + {name: "owner renames", userID: 1, listID: listID, action: domain.ActionEditListMeta}, + {name: "owner deletes", userID: 1, listID: listID, action: domain.ActionDeleteList}, + {name: "editor views", userID: 2, listID: listID, action: domain.ActionViewList}, + {name: "editor cannot rename", userID: 2, listID: listID, action: domain.ActionEditListMeta, wantErr: domain.ErrForbidden}, + {name: "editor cannot delete", userID: 2, listID: listID, action: domain.ActionDeleteList, wantErr: domain.ErrForbidden}, + {name: "reader views", userID: 3, listID: listID, action: domain.ActionViewList}, + {name: "reader cannot rename", userID: 3, listID: listID, action: domain.ActionEditListMeta, wantErr: domain.ErrForbidden}, + {name: "stranger gets not found", userID: 4, listID: listID, action: domain.ActionViewList, wantErr: domain.ErrNotFound}, + {name: "missing list", userID: 1, listID: 99, action: domain.ActionViewList, wantErr: domain.ErrNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := access.Authorize(context.Background(), tt.userID, tt.listID, tt.action) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("Authorize() error = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/service/list.go b/internal/service/list.go new file mode 100644 index 0000000..71c6102 --- /dev/null +++ b/internal/service/list.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +const maxTitleLength = 120 + +type ListRepository interface { + Create(ctx context.Context, ownerID int64, title string) (domain.List, error) + Get(ctx context.Context, id int64) (domain.List, error) + ListForUser(ctx context.Context, userID int64) ([]domain.List, error) + Rename(ctx context.Context, id int64, title string) error + Delete(ctx context.Context, id int64) error + Role(ctx context.Context, listID, userID int64) (domain.Role, bool, error) +} + +type ListService struct { + lists ListRepository + access *AccessService +} + +func NewListService(lists ListRepository, access *AccessService) *ListService { + return &ListService{lists: lists, access: access} +} + +func (s *ListService) Create(ctx context.Context, userID int64, title string) (domain.List, error) { + title, err := validateTitle(title) + if err != nil { + return domain.List{}, err + } + return s.lists.Create(ctx, userID, title) +} + +func (s *ListService) Rename(ctx context.Context, userID, listID int64, title string) (domain.List, error) { + title, err := validateTitle(title) + if err != nil { + return domain.List{}, err + } + if _, err := s.access.Authorize(ctx, userID, listID, domain.ActionEditListMeta); err != nil { + return domain.List{}, err + } + if err := s.lists.Rename(ctx, listID, title); err != nil { + return domain.List{}, err + } + return s.lists.Get(ctx, listID) +} + +func (s *ListService) Delete(ctx context.Context, userID, listID int64) error { + if _, err := s.access.Authorize(ctx, userID, listID, domain.ActionDeleteList); err != nil { + return err + } + return s.lists.Delete(ctx, listID) +} + +func (s *ListService) List(ctx context.Context, userID int64) ([]domain.List, error) { + return s.lists.ListForUser(ctx, userID) +} + +func validateTitle(title string) (string, error) { + title = strings.TrimSpace(title) + if title == "" || utf8.RuneCountInString(title) > maxTitleLength { + return "", fmt.Errorf("%w: title must be 1-%d characters", domain.ErrInvalidInput, maxTitleLength) + } + return title, nil +} diff --git a/internal/service/list_test.go b/internal/service/list_test.go new file mode 100644 index 0000000..359a375 --- /dev/null +++ b/internal/service/list_test.go @@ -0,0 +1,33 @@ +package service + +import ( + "strings" + "testing" +) + +func TestValidateTitle(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "trims", input: " Groceries ", want: "Groceries"}, + {name: "empty", input: "", wantErr: true}, + {name: "spaces only", input: " ", wantErr: true}, + {name: "max length", input: strings.Repeat("a", 120), want: strings.Repeat("a", 120)}, + {name: "too long", input: strings.Repeat("a", 121), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateTitle(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("validateTitle() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("validateTitle() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/service/session.go b/internal/service/session.go new file mode 100644 index 0000000..21eaac6 --- /dev/null +++ b/internal/service/session.go @@ -0,0 +1,76 @@ +package service + +import ( + "sync" + "time" +) + +type PendingAction string + +const ( + PendingCreateList PendingAction = "create_list" + PendingRenameList PendingAction = "rename_list" +) + +type Pending struct { + Action PendingAction + ListID int64 + expiresAt time.Time +} + +type PendingStore struct { + mu sync.Mutex + ttl time.Duration + items map[int64]Pending +} + +func NewPendingStore(ttl time.Duration) *PendingStore { + // shortcut: expired entries stay in memory until the user writes again; add a janitor if user count grows. + return &PendingStore{ + ttl: ttl, + items: make(map[int64]Pending), + } +} + +func (s *PendingStore) Set(telegramID int64, action PendingAction, listID int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.items[telegramID] = Pending{ + Action: action, + ListID: listID, + expiresAt: time.Now().Add(s.ttl), + } +} + +func (s *PendingStore) Take(telegramID int64) (Pending, bool) { + s.mu.Lock() + defer s.mu.Unlock() + pending, ok := s.items[telegramID] + delete(s.items, telegramID) + if !ok || time.Now().After(pending.expiresAt) { + return Pending{}, false + } + return pending, true +} + +func (s *PendingStore) Get(telegramID int64) (Pending, bool) { + s.mu.Lock() + defer s.mu.Unlock() + pending, ok := s.items[telegramID] + if !ok { + return Pending{}, false + } + if time.Now().After(pending.expiresAt) { + delete(s.items, telegramID) + return Pending{}, false + } + return pending, true +} + +func (s *PendingStore) Clear(telegramID int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + pending, ok := s.items[telegramID] + delete(s.items, telegramID) + return ok && !time.Now().After(pending.expiresAt) +} diff --git a/internal/service/session_test.go b/internal/service/session_test.go new file mode 100644 index 0000000..42d44f4 --- /dev/null +++ b/internal/service/session_test.go @@ -0,0 +1,53 @@ +package service + +import ( + "testing" + "time" +) + +func TestPendingStore(t *testing.T) { + store := NewPendingStore(time.Minute) + store.Set(1, PendingCreateList, 0) + + pending, ok := store.Take(1) + if !ok || pending.Action != PendingCreateList { + t.Fatalf("Take() = %+v, %v", pending, ok) + } + if _, ok := store.Take(1); ok { + t.Fatal("Take() returned a consumed pending value") + } + + store.Set(2, PendingRenameList, 10) + if !store.Clear(2) { + t.Fatal("Clear() = false, want true") + } + if store.Clear(2) { + t.Fatal("Clear() = true for an empty value") + } +} + +func TestPendingStoreExpiry(t *testing.T) { + store := NewPendingStore(0) + store.Set(1, PendingCreateList, 0) + + if _, ok := store.Take(1); ok { + t.Fatal("Take() returned an expired pending value") + } +} + +func TestPendingStoreGetDoesNotConsume(t *testing.T) { + store := NewPendingStore(time.Minute) + store.Set(1, PendingRenameList, 10) + + first, ok := store.Get(1) + if !ok || first.Action != PendingRenameList || first.ListID != 10 { + t.Fatalf("Get() = %+v, %v", first, ok) + } + second, ok := store.Get(1) + if !ok || second.Action != PendingRenameList { + t.Fatalf("second Get() = %+v, %v", second, ok) + } + if _, ok := store.Take(1); !ok { + t.Fatal("Take() after Get() returned no pending value") + } +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..d423942 --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,23 @@ +package service + +import ( + "context" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type UserRepository interface { + Ensure(ctx context.Context, telegramID int64) (domain.User, error) +} + +type UserService struct { + users UserRepository +} + +func NewUserService(users UserRepository) *UserService { + return &UserService{users: users} +} + +func (s *UserService) Ensure(ctx context.Context, telegramID int64) (domain.User, error) { + return s.users.Ensure(ctx, telegramID) +} diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go new file mode 100644 index 0000000..85a1b21 --- /dev/null +++ b/internal/telegram/bot.go @@ -0,0 +1,86 @@ +package telegram + +import ( + "context" + "fmt" + "time" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/list" + "git.entityseven.com/rilaxik/pllist/internal/telegram/menu" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +const ( + shutdownTimeout = 10 * time.Second + updateTimeout = 30 * time.Second +) + +type Bot struct { + api *telego.Bot + handler *th.BotHandler + users *service.UserService + pending *service.PendingStore + sender *shared.Sender + pendingRouter *shared.PendingRouter + menu *menu.Handler + list *list.Handler + locker *keyedLocker +} + +func New( + api *telego.Bot, + updates <-chan telego.Update, + users *service.UserService, + lists *service.ListService, + access *service.AccessService, + pending *service.PendingStore, +) (*Bot, error) { + sender := shared.NewSender(api) + bot := &Bot{ + api: api, + users: users, + pending: pending, + sender: sender, + pendingRouter: shared.NewPendingRouter(), + menu: menu.New(sender, pending), + list: list.New(sender, lists, access, pending), + locker: newKeyedLocker(), + } + + handler, err := th.NewBotHandler(api, updates, th.WithErrorHandler(bot.handleError)) + if err != nil { + return nil, fmt.Errorf("create bot handler: %w", err) + } + bot.handler = handler + + handler.Use( + th.PanicRecoveryHandler(func(recovered any) error { + return fmt.Errorf("panic: %v", recovered) + }), + th.Timeout(updateTimeout), + bot.answerCallback, + bot.serialize, + bot.privateOnly, + bot.withUser, + ) + bot.register() + + return bot, nil +} + +func (b *Bot) Run() error { + if err := b.handler.Start(); err != nil { + return fmt.Errorf("start bot handler: %w", err) + } + + stopCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := b.handler.StopWithContext(stopCtx); err != nil { + return fmt.Errorf("stop bot handler: %w", err) + } + return nil +} diff --git a/internal/telegram/handlers.go b/internal/telegram/handlers.go new file mode 100644 index 0000000..40822fc --- /dev/null +++ b/internal/telegram/handlers.go @@ -0,0 +1,43 @@ +package telegram + +import ( + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func (b *Bot) handleUnknownCommand(ctx *th.Context, message telego.Message) error { + return b.sender.Send(ctx, message.Chat.ID, unknownCommandText, nil) +} + +func (b *Bot) handleText(ctx *th.Context, message telego.Message) error { + user := shared.UserFrom(ctx) + pending, ok := b.pending.Take(user.TelegramID) + if !ok { + return b.sender.Send(ctx, message.Chat.ID, helpHintText, nil) + } + + handled, err := b.pendingRouter.Dispatch(ctx, user, pending, message) + if err != nil { + return err + } + if !handled { + return b.sender.Send(ctx, message.Chat.ID, promptExpiredText, nil) + } + return nil +} + +func (b *Bot) handleOtherMessage(ctx *th.Context, message telego.Message) error { + if _, ok := b.pending.Get(shared.UserFrom(ctx).TelegramID); !ok { + return nil + } + return b.sender.Send(ctx, message.Chat.ID, textOnlyText, nil) +} + +// handleUnknownCallback catches buttons from an older bot version, including +// callbacks whose message is no longer accessible. The callback is answered by +// middleware; leaving the message untouched is enough. +func (b *Bot) handleUnknownCallback(_ *th.Context, _ telego.CallbackQuery) error { + return nil +} diff --git a/internal/telegram/list/callback_data.go b/internal/telegram/list/callback_data.go new file mode 100644 index 0000000..96eb111 --- /dev/null +++ b/internal/telegram/list/callback_data.go @@ -0,0 +1,8 @@ +package list + +const ( + callbackList = "list" + callbackRename = "rename" + callbackDelete = "delete" + callbackDeleteYes = "delete_yes" +) diff --git a/internal/telegram/list/callback_data_test.go b/internal/telegram/list/callback_data_test.go new file mode 100644 index 0000000..912903e --- /dev/null +++ b/internal/telegram/list/callback_data_test.go @@ -0,0 +1,20 @@ +package list + +import ( + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func TestListCallbackDataRoundTrip(t *testing.T) { + for _, action := range []string{callbackList, callbackRename, callbackDelete, callbackDeleteYes} { + data := shared.CallbackWithID(action, 42) + got, err := shared.CallbackID(data, action) + if err != nil { + t.Fatalf("CallbackID(%q) error = %v", data, err) + } + if got != 42 { + t.Fatalf("CallbackID(%q) = %d, want 42", data, got) + } + } +} diff --git a/internal/telegram/list/callbacks.go b/internal/telegram/list/callbacks.go new file mode 100644 index 0000000..bebfed7 --- /dev/null +++ b/internal/telegram/list/callbacks.go @@ -0,0 +1,64 @@ +package list + +import ( + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func (h *Handler) listsCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.showLists(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query)) +} + +func (h *Handler) newCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.startCreateList(ctx, shared.QueryChatID(query), shared.UserFrom(ctx).TelegramID) +} + +func (h *Handler) listCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := shared.CallbackID(query.Data, callbackList) + if err != nil { + return err + } + return h.showList(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query), listID) +} + +func (h *Handler) renameCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := shared.CallbackID(query.Data, callbackRename) + if err != nil { + return err + } + if _, err := h.access.Authorize(ctx, shared.UserFrom(ctx).ID, listID, domain.ActionEditListMeta); err != nil { + return err + } + h.pending.Set(shared.UserFrom(ctx).TelegramID, service.PendingRenameList, listID) + return h.sender.Send(ctx, shared.QueryChatID(query), renameTitlePrompt, nil) +} + +func (h *Handler) deleteCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := shared.CallbackID(query.Data, callbackDelete) + if err != nil { + return err + } + if _, err := h.access.Authorize(ctx, shared.UserFrom(ctx).ID, listID, domain.ActionDeleteList); err != nil { + return err + } + return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query), + deleteText, deleteKeyboard(listID)) +} + +func (h *Handler) deleteYesCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := shared.CallbackID(query.Data, callbackDeleteYes) + if err != nil { + return err + } + if err := h.lists.Delete(ctx, shared.UserFrom(ctx).ID, listID); err != nil { + return err + } + return h.showLists(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query)) +} diff --git a/internal/telegram/list/commands.go b/internal/telegram/list/commands.go new file mode 100644 index 0000000..540c706 --- /dev/null +++ b/internal/telegram/list/commands.go @@ -0,0 +1,22 @@ +package list + +import ( + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func (h *Handler) handleLists(ctx *th.Context, message telego.Message) error { + return h.showLists(ctx, shared.UserFrom(ctx).ID, message.Chat.ID, 0) +} + +func (h *Handler) handleNew(ctx *th.Context, message telego.Message) error { + return h.startCreateList(ctx, message.Chat.ID, shared.UserFrom(ctx).TelegramID) +} + +func (h *Handler) startCreateList(ctx *th.Context, chatID, telegramID int64) error { + h.pending.Set(telegramID, service.PendingCreateList, 0) + return h.sender.Send(ctx, chatID, titlePrompt, nil) +} diff --git a/internal/telegram/list/handler.go b/internal/telegram/list/handler.go new file mode 100644 index 0000000..c1b3073 --- /dev/null +++ b/internal/telegram/list/handler.go @@ -0,0 +1,45 @@ +package list + +import ( + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +type Handler struct { + sender *shared.Sender + lists *service.ListService + access *service.AccessService + pending *service.PendingStore +} + +func New( + sender *shared.Sender, + lists *service.ListService, + access *service.AccessService, + pending *service.PendingStore, +) *Handler { + return &Handler{ + sender: sender, + lists: lists, + access: access, + pending: pending, + } +} + +func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) { + router.HandleMessage(h.handleLists, th.CommandEqual("lists")) + router.HandleMessage(h.handleNew, th.CommandEqual("new")) + + callbacks := router.Group(th.AnyCallbackQueryWithMessage()) + callbacks.HandleCallbackQuery(h.listsCallback, th.CallbackDataEqual(shared.NavLists)) + callbacks.HandleCallbackQuery(h.newCallback, th.CallbackDataEqual(shared.NavNew)) + callbacks.HandleCallbackQuery(h.listCallback, th.CallbackDataPrefix(callbackList+":")) + callbacks.HandleCallbackQuery(h.renameCallback, th.CallbackDataPrefix(callbackRename+":")) + callbacks.HandleCallbackQuery(h.deleteCallback, th.CallbackDataPrefix(callbackDelete+":")) + callbacks.HandleCallbackQuery(h.deleteYesCallback, th.CallbackDataPrefix(callbackDeleteYes+":")) + + pendingRouter.Register(service.PendingCreateList, h.createText) + pendingRouter.Register(service.PendingRenameList, h.renameText) +} diff --git a/internal/telegram/list/keyboards.go b/internal/telegram/list/keyboards.go new file mode 100644 index 0000000..e02b661 --- /dev/null +++ b/internal/telegram/list/keyboards.go @@ -0,0 +1,47 @@ +package list + +import ( + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func listsKeyboard(lists []domain.List) *telego.InlineKeyboardMarkup { + rows := make([][]telego.InlineKeyboardButton, 0, len(lists)+1) + for _, list := range lists { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button(shared.Truncate(list.Title, 40), + shared.CallbackWithID(callbackList, list.ID)), + )) + } + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("New list", shared.NavNew), + shared.Button("Help", shared.NavHelp), + )) + return tu.InlineKeyboard(rows...) +} + +func listKeyboard(access domain.Access) *telego.InlineKeyboardMarkup { + rows := make([][]telego.InlineKeyboardButton, 0, 2) + if access.Role == domain.RoleOwner { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Rename", shared.CallbackWithID(callbackRename, access.List.ID)), + shared.Button("Delete", shared.CallbackWithID(callbackDelete, access.List.ID)), + )) + } + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Back to lists", shared.NavLists), + )) + return tu.InlineKeyboard(rows...) +} + +func deleteKeyboard(listID int64) *telego.InlineKeyboardMarkup { + return tu.InlineKeyboard( + tu.InlineKeyboardRow( + shared.Button("Yes, delete", shared.CallbackWithID(callbackDeleteYes, listID)), + shared.Button("Cancel", shared.CallbackWithID(callbackList, listID)), + ), + ) +} diff --git a/internal/telegram/list/pending.go b/internal/telegram/list/pending.go new file mode 100644 index 0000000..74feca0 --- /dev/null +++ b/internal/telegram/list/pending.go @@ -0,0 +1,35 @@ +package list + +import ( + "errors" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +func (h *Handler) createText( + ctx *th.Context, user domain.User, _ service.Pending, message telego.Message, +) error { + list, err := h.lists.Create(ctx, user.ID, message.Text) + if err != nil { + h.pending.Set(user.TelegramID, service.PendingCreateList, 0) + return err + } + return h.showList(ctx, user.ID, message.Chat.ID, 0, list.ID) +} + +func (h *Handler) renameText( + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, +) error { + list, err := h.lists.Rename(ctx, user.ID, pending.ListID, message.Text) + if err != nil { + if errors.Is(err, domain.ErrInvalidInput) { + h.pending.Set(user.TelegramID, service.PendingRenameList, pending.ListID) + } + return err + } + return h.showList(ctx, user.ID, message.Chat.ID, 0, list.ID) +} diff --git a/internal/telegram/list/text.go b/internal/telegram/list/text.go new file mode 100644 index 0000000..cc2e8d4 --- /dev/null +++ b/internal/telegram/list/text.go @@ -0,0 +1,37 @@ +package list + +import ( + "fmt" + "strings" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +const ( + deleteText = `Delete this list? This cannot be undone.` + titlePrompt = "Send the list title (1-120 characters). /cancel to stop." + renameTitlePrompt = "Send the new list title (1-120 characters). /cancel to stop." +) + +func listsText(lists []domain.List, userID int64) string { + if len(lists) == 0 { + return "Your lists\n\nNo lists yet. Create one with /new." + } + + var text strings.Builder + text.WriteString("Your lists\n\n") + for _, list := range lists { + role := "shared" + if list.OwnerID == userID { + role = "owner" + } + fmt.Fprintf(&text, "• %s #%d — %s\n", shared.Escape(list.Title), list.ID, role) + } + return text.String() +} + +func listText(access domain.Access) string { + return fmt.Sprintf("%s\nID: %d\nRole: %s\n\nNo items yet.", + shared.Escape(access.List.Title), access.List.ID, access.Role) +} diff --git a/internal/telegram/list/views.go b/internal/telegram/list/views.go new file mode 100644 index 0000000..29d6235 --- /dev/null +++ b/internal/telegram/list/views.go @@ -0,0 +1,23 @@ +package list + +import ( + "context" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +func (h *Handler) showLists(ctx context.Context, userID, chatID int64, messageID int) error { + lists, err := h.lists.List(ctx, userID) + if err != nil { + return err + } + return h.sender.EditOrSend(ctx, chatID, messageID, listsText(lists, userID), listsKeyboard(lists)) +} + +func (h *Handler) showList(ctx context.Context, userID, chatID int64, messageID int, listID int64) error { + access, err := h.access.Authorize(ctx, userID, listID, domain.ActionViewList) + if err != nil { + return err + } + return h.sender.EditOrSend(ctx, chatID, messageID, listText(access), listKeyboard(access)) +} diff --git a/internal/telegram/locker.go b/internal/telegram/locker.go new file mode 100644 index 0000000..e2f559f --- /dev/null +++ b/internal/telegram/locker.go @@ -0,0 +1,39 @@ +package telegram + +import "sync" + +type keyedLocker struct { + mu sync.Mutex + locks map[int64]*keyedLock +} + +type keyedLock struct { + mu sync.Mutex + refs int +} + +func newKeyedLocker() *keyedLocker { + return &keyedLocker{locks: make(map[int64]*keyedLock)} +} + +func (l *keyedLocker) Lock(key int64) func() { + l.mu.Lock() + lock := l.locks[key] + if lock == nil { + lock = &keyedLock{} + l.locks[key] = lock + } + lock.refs++ + l.mu.Unlock() + + lock.mu.Lock() + return func() { + lock.mu.Unlock() + l.mu.Lock() + lock.refs-- + if lock.refs == 0 { + delete(l.locks, key) + } + l.mu.Unlock() + } +} diff --git a/internal/telegram/menu/handler.go b/internal/telegram/menu/handler.go new file mode 100644 index 0000000..6261ea5 --- /dev/null +++ b/internal/telegram/menu/handler.go @@ -0,0 +1,55 @@ +package menu + +import ( + "fmt" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +type Handler struct { + sender *shared.Sender + pending *service.PendingStore +} + +func New(sender *shared.Sender, pending *service.PendingStore) *Handler { + return &Handler{sender: sender, pending: pending} +} + +func (h *Handler) Register(router *th.BotHandler) { + router.HandleMessage(h.start, th.CommandEqual("start")) + router.HandleMessage(h.help, th.CommandEqual("help")) + router.HandleMessage(h.id, th.CommandEqual("id")) + router.HandleMessage(h.cancel, th.CommandEqual("cancel")) + + callbacks := router.Group(th.AnyCallbackQueryWithMessage()) + callbacks.HandleCallbackQuery(h.helpCallback, th.CallbackDataEqual(shared.NavHelp)) +} + +func (h *Handler) start(ctx *th.Context, message telego.Message) error { + return h.sender.Send(ctx, message.Chat.ID, startText, startKeyboard()) +} + +func (h *Handler) help(ctx *th.Context, message telego.Message) error { + return h.sender.Send(ctx, message.Chat.ID, helpText, listsButtonKeyboard()) +} + +func (h *Handler) id(ctx *th.Context, message telego.Message) error { + return h.sender.Send(ctx, message.Chat.ID, + fmt.Sprintf("Your Telegram ID: %d", shared.UserFrom(ctx).TelegramID), nil) +} + +func (h *Handler) cancel(ctx *th.Context, message telego.Message) error { + if h.pending.Clear(shared.UserFrom(ctx).TelegramID) { + return h.sender.Send(ctx, message.Chat.ID, "Pending input cancelled.", nil) + } + return h.sender.Send(ctx, message.Chat.ID, "Nothing to cancel.", nil) +} + +func (h *Handler) helpCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query), + helpText, listsButtonKeyboard()) +} diff --git a/internal/telegram/menu/keyboards.go b/internal/telegram/menu/keyboards.go new file mode 100644 index 0000000..2c59373 --- /dev/null +++ b/internal/telegram/menu/keyboards.go @@ -0,0 +1,24 @@ +package menu + +import ( + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func startKeyboard() *telego.InlineKeyboardMarkup { + return tu.InlineKeyboard( + tu.InlineKeyboardRow( + shared.Button("My lists", shared.NavLists), + shared.Button("New list", shared.NavNew), + ), + tu.InlineKeyboardRow(shared.Button("Help", shared.NavHelp)), + ) +} + +func listsButtonKeyboard() *telego.InlineKeyboardMarkup { + return tu.InlineKeyboard( + tu.InlineKeyboardRow(shared.Button("My lists", shared.NavLists)), + ) +} diff --git a/internal/telegram/menu/text.go b/internal/telegram/menu/text.go new file mode 100644 index 0000000..110dacb --- /dev/null +++ b/internal/telegram/menu/text.go @@ -0,0 +1,16 @@ +package menu + +const ( + startText = `pllist +Keep lists of planned purchases, share them, and reserve items. + +Use the buttons below or /help.` + helpText = `Commands +/lists - your lists +/new - create a list +/cancel - cancel pending input +/id - your Telegram ID +/help - this message + +Open a list to rename or delete it.` +) diff --git a/internal/telegram/messages.go b/internal/telegram/messages.go new file mode 100644 index 0000000..8a5e4f8 --- /dev/null +++ b/internal/telegram/messages.go @@ -0,0 +1,9 @@ +package telegram + +const ( + privateChatOnlyText = "I work in private chats only." + unknownCommandText = "Unknown command. Use /help." + helpHintText = "Use /help to see available commands." + textOnlyText = "Send text only." + promptExpiredText = "That prompt is no longer active. Use /help." +) diff --git a/internal/telegram/middleware.go b/internal/telegram/middleware.go new file mode 100644 index 0000000..6df1b82 --- /dev/null +++ b/internal/telegram/middleware.go @@ -0,0 +1,68 @@ +package telegram + +import ( + "log" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func (b *Bot) answerCallback(ctx *th.Context, update telego.Update) error { + if update.CallbackQuery != nil { + err := b.api.AnswerCallbackQuery(ctx, &telego.AnswerCallbackQueryParams{ + CallbackQueryID: update.CallbackQuery.ID, + }) + if err != nil { + log.Printf("answer callback %s: %v", update.CallbackQuery.ID, err) + } + } + return ctx.Next(update) +} + +// serialize runs updates from the same Telegram user one at a time. Telego +// handles updates in parallel, while pending prompts and list edits are stateful. +func (b *Bot) serialize(ctx *th.Context, update telego.Update) error { + from := updateFrom(update) + if from == nil { + return ctx.Next(update) + } + unlock := b.locker.Lock(from.ID) + defer unlock() + return ctx.Next(update) +} + +func (b *Bot) privateOnly(ctx *th.Context, update telego.Update) error { + chatID, ok := updateChatID(update) + if !ok { + return ctx.Next(update) + } + if !isPrivateChat(update) { + return b.sender.Send(ctx, chatID, privateChatOnlyText, nil) + } + return ctx.Next(update) +} + +func (b *Bot) withUser(ctx *th.Context, update telego.Update) error { + from := updateFrom(update) + if from == nil || from.IsBot { + return nil + } + user, err := b.users.Ensure(ctx, from.ID) + if err != nil { + return err + } + return shared.WithUser(ctx, user).Next(update) +} + +func (b *Bot) handleError(ctx *th.Context, update telego.Update, err error) { + log.Printf("update %d: %v", update.UpdateID, err) + chatID, ok := updateChatID(update) + if !ok { + return + } + if sendErr := b.sender.Send(ctx, chatID, shared.UserMessage(err), nil); sendErr != nil { + log.Printf("send error message: %v", sendErr) + } +} diff --git a/internal/telegram/middleware_test.go b/internal/telegram/middleware_test.go new file mode 100644 index 0000000..c93f9db --- /dev/null +++ b/internal/telegram/middleware_test.go @@ -0,0 +1,50 @@ +package telegram + +import ( + "testing" + "time" +) + +func TestKeyedLockerSerializesSameKey(t *testing.T) { + locker := newKeyedLocker() + unlock := locker.Lock(1) + + acquired := make(chan struct{}) + go func() { + unlockSecond := locker.Lock(1) + close(acquired) + unlockSecond() + }() + + select { + case <-acquired: + t.Fatal("second lock acquired while first is held") + case <-time.After(20 * time.Millisecond): + } + + unlock() + select { + case <-acquired: + case <-time.After(time.Second): + t.Fatal("second lock was not acquired after release") + } +} + +func TestKeyedLockerAllowsDifferentKeys(t *testing.T) { + locker := newKeyedLocker() + unlock := locker.Lock(1) + defer unlock() + + acquired := make(chan struct{}) + go func() { + unlockSecond := locker.Lock(2) + close(acquired) + unlockSecond() + }() + + select { + case <-acquired: + case <-time.After(time.Second): + t.Fatal("different key was blocked") + } +} diff --git a/internal/telegram/routes.go b/internal/telegram/routes.go new file mode 100644 index 0000000..b47cbea --- /dev/null +++ b/internal/telegram/routes.go @@ -0,0 +1,14 @@ +package telegram + +import th "github.com/mymmrac/telego/telegohandler" + +func (b *Bot) register() { + b.menu.Register(b.handler) + b.list.Register(b.handler, b.pendingRouter) + + // Catch-alls go last so feature routes match first. + b.handler.HandleMessage(b.handleUnknownCommand, th.AnyCommand()) + b.handler.HandleMessage(b.handleText, th.AnyMessageWithText()) + b.handler.HandleMessage(b.handleOtherMessage, th.AnyMessage()) + b.handler.HandleCallbackQuery(b.handleUnknownCallback) +} diff --git a/internal/telegram/shared/callback.go b/internal/telegram/shared/callback.go new file mode 100644 index 0000000..d6d09de --- /dev/null +++ b/internal/telegram/shared/callback.go @@ -0,0 +1,46 @@ +package shared + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/mymmrac/telego" +) + +var ErrBadCallback = errors.New("invalid callback data") + +// Navigation callbacks are shared because several features render the same +// menu buttons. +const ( + NavLists = "lists" + NavNew = "new" + NavHelp = "help" +) + +func CallbackWithID(action string, id int64) string { + return fmt.Sprintf("%s:%d", action, id) +} + +func CallbackID(data, action string) (int64, error) { + rawID, ok := strings.CutPrefix(data, action+":") + if !ok { + return 0, ErrBadCallback + } + id, err := strconv.ParseInt(rawID, 10, 64) + if err != nil || id <= 0 { + return 0, ErrBadCallback + } + return id, nil +} + +// QueryChatID and QueryMessageID assume the callback was routed with +// telegohandler.AnyCallbackQueryWithMessage. +func QueryChatID(query telego.CallbackQuery) int64 { + return query.Message.GetChat().ID +} + +func QueryMessageID(query telego.CallbackQuery) int { + return query.Message.GetMessageID() +} diff --git a/internal/telegram/shared/callback_test.go b/internal/telegram/shared/callback_test.go new file mode 100644 index 0000000..ac892c8 --- /dev/null +++ b/internal/telegram/shared/callback_test.go @@ -0,0 +1,40 @@ +package shared + +import "testing" + +func TestCallbackID(t *testing.T) { + tests := []struct { + name string + data string + action string + want int64 + wantErr bool + }{ + {name: "valid", data: "list:42", action: "list", want: 42}, + {name: "wrong prefix", data: "rename:42", action: "list", wantErr: true}, + {name: "not a number", data: "list:abc", action: "list", wantErr: true}, + {name: "zero", data: "list:0", action: "list", wantErr: true}, + {name: "missing id", data: "list", action: "list", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CallbackID(tt.data, tt.action) + if (err != nil) != tt.wantErr { + t.Fatalf("CallbackID() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("CallbackID() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestCallbackWithIDRoundTrip(t *testing.T) { + for _, action := range []string{NavLists, NavNew, NavHelp} { + data := CallbackWithID(action, 7) + if data != action+":7" { + t.Fatalf("CallbackWithID() = %q", data) + } + } +} diff --git a/internal/telegram/shared/pending.go b/internal/telegram/shared/pending.go new file mode 100644 index 0000000..24be34d --- /dev/null +++ b/internal/telegram/shared/pending.go @@ -0,0 +1,38 @@ +package shared + +import ( + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +// PendingHandler consumes free text for a feature after its prompt. +type PendingHandler func( + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, +) error + +// PendingRouter lets features own their prompt handlers without making the +// root dispatcher know every feature. +type PendingRouter struct { + handlers map[service.PendingAction]PendingHandler +} + +func NewPendingRouter() *PendingRouter { + return &PendingRouter{handlers: make(map[service.PendingAction]PendingHandler)} +} + +func (r *PendingRouter) Register(action service.PendingAction, handler PendingHandler) { + r.handlers[action] = handler +} + +func (r *PendingRouter) Dispatch( + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, +) (bool, error) { + handler, ok := r.handlers[pending.Action] + if !ok { + return false, nil + } + return true, handler(ctx, user, pending, message) +} diff --git a/internal/telegram/shared/pending_test.go b/internal/telegram/shared/pending_test.go new file mode 100644 index 0000000..bdec915 --- /dev/null +++ b/internal/telegram/shared/pending_test.go @@ -0,0 +1,42 @@ +package shared + +import ( + "testing" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +func TestPendingRouterDispatch(t *testing.T) { + router := NewPendingRouter() + called := false + router.Register(service.PendingCreateList, func( + _ *th.Context, _ domain.User, _ service.Pending, _ telego.Message, + ) error { + called = true + return nil + }) + + handled, err := router.Dispatch(nil, domain.User{}, service.Pending{ + Action: service.PendingCreateList, + }, telego.Message{}) + if err != nil { + t.Fatalf("Dispatch() error = %v", err) + } + if !handled || !called { + t.Fatalf("Dispatch() handled = %v, called = %v", handled, called) + } + + handled, err = router.Dispatch(nil, domain.User{}, service.Pending{ + Action: "unknown", + }, telego.Message{}) + if err != nil { + t.Fatalf("Dispatch() error = %v", err) + } + if handled { + t.Fatal("Dispatch() handled an unknown action") + } +} diff --git a/internal/telegram/shared/sender.go b/internal/telegram/shared/sender.go new file mode 100644 index 0000000..556cc78 --- /dev/null +++ b/internal/telegram/shared/sender.go @@ -0,0 +1,52 @@ +package shared + +import ( + "context" + "fmt" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// Sender is the Telegram transport shared by all features. +type Sender struct { + api *telego.Bot +} + +func NewSender(api *telego.Bot) *Sender { + return &Sender{api: api} +} + +func (s *Sender) Send( + ctx context.Context, chatID int64, text string, keyboard *telego.InlineKeyboardMarkup, +) error { + _, err := s.api.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: tu.ID(chatID), + Text: text, + ParseMode: telego.ModeHTML, + ReplyMarkup: keyboard, + }) + if err != nil { + return fmt.Errorf("send message: %w", err) + } + return nil +} + +func (s *Sender) EditOrSend( + ctx context.Context, chatID int64, messageID int, text string, keyboard *telego.InlineKeyboardMarkup, +) error { + if messageID == 0 { + return s.Send(ctx, chatID, text, keyboard) + } + _, err := s.api.EditMessageText(ctx, &telego.EditMessageTextParams{ + ChatID: tu.ID(chatID), + MessageID: messageID, + Text: text, + ParseMode: telego.ModeHTML, + ReplyMarkup: keyboard, + }) + if err != nil { + return fmt.Errorf("edit message: %w", err) + } + return nil +} diff --git a/internal/telegram/shared/text.go b/internal/telegram/shared/text.go new file mode 100644 index 0000000..fc00ced --- /dev/null +++ b/internal/telegram/shared/text.go @@ -0,0 +1,22 @@ +package shared + +import ( + "errors" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +func UserMessage(err error) string { + switch { + case errors.Is(err, domain.ErrNotFound): + return "List not found or no access." + case errors.Is(err, domain.ErrForbidden): + return "You cannot do that." + case errors.Is(err, domain.ErrInvalidInput): + return "Title must be 1-120 characters." + case errors.Is(err, ErrBadCallback): + return "This button is out of date. Use /lists." + default: + return "Something went wrong. Try again." + } +} diff --git a/internal/telegram/shared/ui.go b/internal/telegram/shared/ui.go new file mode 100644 index 0000000..fbf9da2 --- /dev/null +++ b/internal/telegram/shared/ui.go @@ -0,0 +1,27 @@ +package shared + +import ( + "strings" + "unicode/utf8" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +var escaper = strings.NewReplacer("&", "&", "<", "<", ">", ">") + +func Button(text, data string) telego.InlineKeyboardButton { + return tu.InlineKeyboardButton(text).WithCallbackData(data) +} + +func Escape(text string) string { + return escaper.Replace(text) +} + +func Truncate(text string, max int) string { + if utf8.RuneCountInString(text) <= max { + return text + } + runes := []rune(text) + return string(runes[:max-1]) + "…" +} diff --git a/internal/telegram/shared/user.go b/internal/telegram/shared/user.go new file mode 100644 index 0000000..a1c0b82 --- /dev/null +++ b/internal/telegram/shared/user.go @@ -0,0 +1,18 @@ +package shared + +import ( + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type userKey struct{} + +func WithUser(ctx *th.Context, user domain.User) *th.Context { + return ctx.WithValue(userKey{}, user) +} + +func UserFrom(ctx *th.Context) domain.User { + user, _ := ctx.Value(userKey{}).(domain.User) + return user +} diff --git a/internal/telegram/util.go b/internal/telegram/util.go new file mode 100644 index 0000000..9c4b206 --- /dev/null +++ b/internal/telegram/util.go @@ -0,0 +1,33 @@ +package telegram + +import "github.com/mymmrac/telego" + +func updateFrom(update telego.Update) *telego.User { + if update.Message != nil { + return update.Message.From + } + if update.CallbackQuery != nil { + return &update.CallbackQuery.From + } + return nil +} + +func updateChatID(update telego.Update) (int64, bool) { + if update.Message != nil { + return update.Message.Chat.ID, true + } + if update.CallbackQuery != nil && update.CallbackQuery.Message != nil { + return update.CallbackQuery.Message.GetChat().ID, true + } + return 0, false +} + +func isPrivateChat(update telego.Update) bool { + if update.Message != nil { + return update.Message.Chat.Type == telego.ChatTypePrivate + } + if update.CallbackQuery != nil && update.CallbackQuery.Message != nil { + return update.CallbackQuery.Message.GetChat().Type == telego.ChatTypePrivate + } + return true +} -- 2.54.0 From c7684893bb2b03959836b34d43a41c5c9398720c Mon Sep 17 00:00:00 2001 From: Danya H Date: Sat, 7 Nov 2026 19:18:19 +0000 Subject: [PATCH 3/9] feat: roadmap M1 --- docs/architecture.md | 2 +- docs/roadmap-mvp.md | 2 +- internal/telegram/list/text.go | 6 ++-- internal/telegram/menu/handler.go | 8 ----- internal/telegram/menu/text.go | 1 - internal/telegram/shared/sender.go | 46 +++++++++++++++++------- internal/telegram/shared/sender_test.go | 48 +++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 27 deletions(-) create mode 100644 internal/telegram/shared/sender_test.go diff --git a/docs/architecture.md b/docs/architecture.md index bae9b38..b974ece 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ docs/ architecture, infrastructure, plan, notes telegram/ bot.go, routes.go, middleware.go lifecycle, routing, identity, per-user ordering shared/ sender, user context, nav callbacks, pending text router - menu/ /start, /help, /id, /cancel + menu/ /start, /help, /cancel list/ list screens, commands, callbacks, prompts ``` diff --git a/docs/roadmap-mvp.md b/docs/roadmap-mvp.md index 47ecf06..ad01dc8 100644 --- a/docs/roadmap-mvp.md +++ b/docs/roadmap-mvp.md @@ -12,7 +12,7 @@ ## M1 — Users and lists - Ensure a user row on every update; store only the Telegram ID. -- Commands: `/start`, `/help`, `/lists`, `/new`, `/cancel`, `/id`. +- Commands: `/start`, `/help`, `/lists`, `/new`, `/cancel`. - Create, rename, and soft-delete a list. Owner only for rename and delete. - List index with owned and shared lists. diff --git a/internal/telegram/list/text.go b/internal/telegram/list/text.go index cc2e8d4..f072120 100644 --- a/internal/telegram/list/text.go +++ b/internal/telegram/list/text.go @@ -26,12 +26,12 @@ func listsText(lists []domain.List, userID int64) string { if list.OwnerID == userID { role = "owner" } - fmt.Fprintf(&text, "• %s #%d — %s\n", shared.Escape(list.Title), list.ID, role) + fmt.Fprintf(&text, "• %s — %s\n", shared.Escape(list.Title), role) } return text.String() } func listText(access domain.Access) string { - return fmt.Sprintf("%s\nID: %d\nRole: %s\n\nNo items yet.", - shared.Escape(access.List.Title), access.List.ID, access.Role) + return fmt.Sprintf("%s\nRole: %s\n\nNo items yet.", + shared.Escape(access.List.Title), access.Role) } diff --git a/internal/telegram/menu/handler.go b/internal/telegram/menu/handler.go index 6261ea5..0e75cae 100644 --- a/internal/telegram/menu/handler.go +++ b/internal/telegram/menu/handler.go @@ -1,8 +1,6 @@ package menu import ( - "fmt" - "github.com/mymmrac/telego" th "github.com/mymmrac/telego/telegohandler" @@ -22,7 +20,6 @@ func New(sender *shared.Sender, pending *service.PendingStore) *Handler { func (h *Handler) Register(router *th.BotHandler) { router.HandleMessage(h.start, th.CommandEqual("start")) router.HandleMessage(h.help, th.CommandEqual("help")) - router.HandleMessage(h.id, th.CommandEqual("id")) router.HandleMessage(h.cancel, th.CommandEqual("cancel")) callbacks := router.Group(th.AnyCallbackQueryWithMessage()) @@ -37,11 +34,6 @@ func (h *Handler) help(ctx *th.Context, message telego.Message) error { return h.sender.Send(ctx, message.Chat.ID, helpText, listsButtonKeyboard()) } -func (h *Handler) id(ctx *th.Context, message telego.Message) error { - return h.sender.Send(ctx, message.Chat.ID, - fmt.Sprintf("Your Telegram ID: %d", shared.UserFrom(ctx).TelegramID), nil) -} - func (h *Handler) cancel(ctx *th.Context, message telego.Message) error { if h.pending.Clear(shared.UserFrom(ctx).TelegramID) { return h.sender.Send(ctx, message.Chat.ID, "Pending input cancelled.", nil) diff --git a/internal/telegram/menu/text.go b/internal/telegram/menu/text.go index 110dacb..69ed8d6 100644 --- a/internal/telegram/menu/text.go +++ b/internal/telegram/menu/text.go @@ -9,7 +9,6 @@ Use the buttons below or /help.` /lists - your lists /new - create a list /cancel - cancel pending input -/id - your Telegram ID /help - this message Open a list to rename or delete it.` diff --git a/internal/telegram/shared/sender.go b/internal/telegram/shared/sender.go index 556cc78..91d7a69 100644 --- a/internal/telegram/shared/sender.go +++ b/internal/telegram/shared/sender.go @@ -20,12 +20,7 @@ func NewSender(api *telego.Bot) *Sender { func (s *Sender) Send( ctx context.Context, chatID int64, text string, keyboard *telego.InlineKeyboardMarkup, ) error { - _, err := s.api.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: tu.ID(chatID), - Text: text, - ParseMode: telego.ModeHTML, - ReplyMarkup: keyboard, - }) + _, err := s.api.SendMessage(ctx, sendParams(chatID, text, keyboard)) if err != nil { return fmt.Errorf("send message: %w", err) } @@ -38,15 +33,40 @@ func (s *Sender) EditOrSend( if messageID == 0 { return s.Send(ctx, chatID, text, keyboard) } - _, err := s.api.EditMessageText(ctx, &telego.EditMessageTextParams{ - ChatID: tu.ID(chatID), - MessageID: messageID, - Text: text, - ParseMode: telego.ModeHTML, - ReplyMarkup: keyboard, - }) + _, err := s.api.EditMessageText(ctx, editParams(chatID, messageID, text, keyboard)) if err != nil { return fmt.Errorf("edit message: %w", err) } return nil } + +// A typed nil pointer stored in the ReplyMarkup interface would marshal as +// null, which Telegram rejects. Only set it when a keyboard exists. +func sendParams( + chatID int64, text string, keyboard *telego.InlineKeyboardMarkup, +) *telego.SendMessageParams { + params := &telego.SendMessageParams{ + ChatID: tu.ID(chatID), + Text: text, + ParseMode: telego.ModeHTML, + } + if keyboard != nil { + params.ReplyMarkup = keyboard + } + return params +} + +func editParams( + chatID int64, messageID int, text string, keyboard *telego.InlineKeyboardMarkup, +) *telego.EditMessageTextParams { + params := &telego.EditMessageTextParams{ + ChatID: tu.ID(chatID), + MessageID: messageID, + Text: text, + ParseMode: telego.ModeHTML, + } + if keyboard != nil { + params.ReplyMarkup = keyboard + } + return params +} diff --git a/internal/telegram/shared/sender_test.go b/internal/telegram/shared/sender_test.go new file mode 100644 index 0000000..5020f07 --- /dev/null +++ b/internal/telegram/shared/sender_test.go @@ -0,0 +1,48 @@ +package shared + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +func TestSendParamsOmitNilKeyboard(t *testing.T) { + raw, err := json.Marshal(sendParams(1, "hello", nil)) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if strings.Contains(string(raw), "reply_markup") { + t.Fatalf("send params contain reply_markup: %s", raw) + } + + keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(Button("Go", "go"))) + raw, err = json.Marshal(sendParams(1, "hello", keyboard)) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if !strings.Contains(string(raw), "reply_markup") { + t.Fatalf("send params missing reply_markup: %s", raw) + } +} + +func TestEditParamsOmitNilKeyboard(t *testing.T) { + raw, err := json.Marshal(editParams(1, 2, "hello", nil)) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if strings.Contains(string(raw), "reply_markup") { + t.Fatalf("edit params contain reply_markup: %s", raw) + } + + keyboard := &telego.InlineKeyboardMarkup{} + raw, err = json.Marshal(editParams(1, 2, "hello", keyboard)) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if !strings.Contains(string(raw), "reply_markup") { + t.Fatalf("edit params missing reply_markup: %s", raw) + } +} -- 2.54.0 From 3ea2b751a05edb240f3e60d3a633f0c7a92153a5 Mon Sep 17 00:00:00 2001 From: Danya H Date: Sat, 7 Nov 2026 20:55:19 +0000 Subject: [PATCH 4/9] feat: roadmap M2 --- cmd/bot/main.go | 4 +- docs/architecture.md | 9 +- internal/domain/errors.go | 13 + internal/domain/item.go | 38 +++ internal/domain/item_test.go | 68 +++++ internal/domain/list.go | 11 + internal/repository/item.go | 213 ++++++++++++++++ internal/repository/models.go | 20 +- internal/service/access_test.go | 13 + internal/service/item.go | 250 +++++++++++++++++++ internal/service/item_service_test.go | 166 ++++++++++++ internal/service/item_test.go | 152 +++++++++++ internal/service/list.go | 14 +- internal/service/list_test.go | 8 +- internal/service/session.go | 16 +- internal/service/session_test.go | 8 +- internal/telegram/bot.go | 7 +- internal/telegram/item/callback_data.go | 66 +++++ internal/telegram/item/callback_data_test.go | 30 +++ internal/telegram/item/callbacks.go | 203 +++++++++++++++ internal/telegram/item/handler.go | 56 +++++ internal/telegram/item/keyboards.go | 142 +++++++++++ internal/telegram/item/keyboards_test.go | 78 ++++++ internal/telegram/item/pending.go | 67 +++++ internal/telegram/item/text.go | 122 +++++++++ internal/telegram/item/text_test.go | 189 ++++++++++++++ internal/telegram/item/views.go | 36 +++ internal/telegram/list/callback_data.go | 8 - internal/telegram/list/callback_data_test.go | 20 -- internal/telegram/list/callbacks.go | 20 +- internal/telegram/list/commands.go | 2 +- internal/telegram/list/handler.go | 11 +- internal/telegram/list/keyboards.go | 21 +- internal/telegram/list/pending.go | 12 +- internal/telegram/list/text.go | 5 - internal/telegram/list/views.go | 14 +- internal/telegram/menu/text.go | 2 +- internal/telegram/routes.go | 1 + internal/telegram/shared/callback.go | 25 ++ internal/telegram/shared/callback_test.go | 27 +- internal/telegram/shared/text.go | 11 +- internal/telegram/shared/text_test.go | 31 +++ 42 files changed, 2092 insertions(+), 117 deletions(-) create mode 100644 internal/domain/item.go create mode 100644 internal/domain/item_test.go create mode 100644 internal/repository/item.go create mode 100644 internal/service/item.go create mode 100644 internal/service/item_service_test.go create mode 100644 internal/service/item_test.go create mode 100644 internal/telegram/item/callback_data.go create mode 100644 internal/telegram/item/callback_data_test.go create mode 100644 internal/telegram/item/callbacks.go create mode 100644 internal/telegram/item/handler.go create mode 100644 internal/telegram/item/keyboards.go create mode 100644 internal/telegram/item/keyboards_test.go create mode 100644 internal/telegram/item/pending.go create mode 100644 internal/telegram/item/text.go create mode 100644 internal/telegram/item/text_test.go create mode 100644 internal/telegram/item/views.go delete mode 100644 internal/telegram/list/callback_data.go delete mode 100644 internal/telegram/list/callback_data_test.go create mode 100644 internal/telegram/shared/text_test.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index acf3bd4..26ba458 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -60,6 +60,8 @@ func run() error { listRepo := repository.NewListRepository(db) access := service.NewAccessService(listRepo) lists := service.NewListService(listRepo, access) + itemRepo := repository.NewItemRepository(db) + items := service.NewItemService(itemRepo, access) pending := service.NewPendingStore(pendingTTL) updates, err := tgBot.UpdatesViaLongPolling(ctx, nil) @@ -67,7 +69,7 @@ func run() error { return fmt.Errorf("start long polling: %w", err) } - bot, err := telegram.New(tgBot, updates, users, lists, access, pending) + bot, err := telegram.New(tgBot, updates, users, lists, items, access, pending) if err != nil { return err } diff --git a/docs/architecture.md b/docs/architecture.md index b974ece..cad8c2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ internal/domain/ entities, enums, invariants, domain errors internal/service/ use cases: users, lists, items, access, sessions internal/repository/ GORM repository implementations internal/database/ connection setup, pool tuning, migrations -internal/bot/ telego client, router, handlers, keyboards, rendering +internal/telegram/ telego routes, feature handlers, keyboards, rendering internal/events/ publisher interface and no-op implementation docs/ architecture, infrastructure, plan, notes ``` @@ -53,13 +53,16 @@ telegram/ bot.go, routes.go, middleware.go lifecycle, routing, identity, per-user ordering shared/ sender, user context, nav callbacks, pending text router menu/ /start, /help, /cancel - list/ list screens, commands, callbacks, prompts + list/ list index, create, rename, delete + item/ list menu, paginated item list, item screens, prompts ``` Features register their own `telegohandler` routes. Cross-feature navigation callbacks live in `shared`. Free-text prompts are dispatched through `shared.PendingRouter`, so the root text handler does not grow with each -feature. `item/` and `share/` get their own packages when M2 and M3 land. +feature. The `item` package owns the list detail screen because it renders item +rows and pagination; the `list` package delegates to it after create and +rename. `share/` gets its own package when M3 lands. ## Layering diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 972a612..fe536c7 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -8,3 +8,16 @@ var ( ErrConflict = errors.New("conflict") ErrInvalidInput = errors.New("invalid input") ) + +// ValidationError carries a user-safe explanation for invalid input. +type ValidationError struct { + Message string +} + +func (e ValidationError) Error() string { return e.Message } + +func (e ValidationError) Is(target error) bool { return target == ErrInvalidInput } + +func NewValidationError(message string) error { + return ValidationError{Message: message} +} diff --git a/internal/domain/item.go b/internal/domain/item.go new file mode 100644 index 0000000..e68532c --- /dev/null +++ b/internal/domain/item.go @@ -0,0 +1,38 @@ +package domain + +import "time" + +type ItemStatus string + +const ( + ItemStatusOpen ItemStatus = "open" + ItemStatusDone ItemStatus = "done" +) + +type Urgency string + +const ( + UrgencyLow Urgency = "low" + UrgencyNormal Urgency = "normal" + UrgencyHigh Urgency = "high" +) + +type Item struct { + ID int64 + ListID int64 + Title string + Note string + Quantity float64 + Urgency Urgency + Status ItemStatus + ReservedByID *int64 + ReservedAt *time.Time + CompletedAt *time.Time + CreatedByID int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (i Item) Reserved() bool { return i.ReservedByID != nil } + +func (i Item) Done() bool { return i.Status == ItemStatusDone } diff --git a/internal/domain/item_test.go b/internal/domain/item_test.go new file mode 100644 index 0000000..2db6944 --- /dev/null +++ b/internal/domain/item_test.go @@ -0,0 +1,68 @@ +package domain + +import "testing" + +func TestAllowed(t *testing.T) { + roles := []Role{RoleOwner, RoleEditor, RoleReader} + tests := []struct { + name string + action Action + want map[Role]bool + }{ + { + name: "view list", + action: ActionViewList, + want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: true}, + }, + { + name: "edit list meta", + action: ActionEditListMeta, + want: map[Role]bool{RoleOwner: true, RoleEditor: false, RoleReader: false}, + }, + { + name: "delete list", + action: ActionDeleteList, + want: map[Role]bool{RoleOwner: true, RoleEditor: false, RoleReader: false}, + }, + { + name: "view items", + action: ActionViewItems, + want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: true}, + }, + { + name: "add item", + action: ActionAddItem, + want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false}, + }, + { + name: "edit item", + action: ActionEditItem, + want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false}, + }, + { + name: "delete item", + action: ActionDeleteItem, + want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false}, + }, + { + name: "change item state", + action: ActionChangeItemState, + want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false}, + }, + { + name: "reserve item", + action: ActionReserveItem, + want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, role := range roles { + if got := Allowed(role, tt.action); got != tt.want[role] { + t.Fatalf("Allowed(%s, %v) = %v, want %v", role, tt.action, got, tt.want[role]) + } + } + }) + } +} diff --git a/internal/domain/list.go b/internal/domain/list.go index d6f51c8..d8b1f01 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -16,6 +16,12 @@ const ( ActionViewList Action = iota ActionEditListMeta ActionDeleteList + ActionViewItems + ActionAddItem + ActionEditItem + ActionDeleteItem + ActionChangeItemState + ActionReserveItem ) type List struct { @@ -37,6 +43,11 @@ func Allowed(role Role, action Action) bool { return role == RoleOwner || role == RoleEditor || role == RoleReader case ActionEditListMeta, ActionDeleteList: return role == RoleOwner + case ActionViewItems: + return role == RoleOwner || role == RoleEditor || role == RoleReader + case ActionAddItem, ActionEditItem, ActionDeleteItem, + ActionChangeItemState, ActionReserveItem: + return role == RoleOwner || role == RoleEditor default: return false } diff --git a/internal/repository/item.go b/internal/repository/item.go new file mode 100644 index 0000000..77e8b96 --- /dev/null +++ b/internal/repository/item.go @@ -0,0 +1,213 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "gorm.io/gorm" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +const itemOrder = `CASE WHEN status = 'open' THEN 0 ELSE 1 END, + CASE urgency WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, + created_at ASC, id ASC` + +type ItemRepository struct { + db *gorm.DB +} + +func NewItemRepository(db *gorm.DB) *ItemRepository { + return &ItemRepository{db: db} +} + +func (r *ItemRepository) Create( + ctx context.Context, listID, createdByID int64, title string, +) (domain.Item, error) { + model := ItemModel{ + ListID: listID, + Title: title, + Quantity: 1, + Urgency: string(domain.UrgencyNormal), + Status: string(domain.ItemStatusOpen), + CreatedByID: createdByID, + } + if err := r.db.WithContext(ctx).Create(&model).Error; err != nil { + return domain.Item{}, fmt.Errorf("create item: %w", err) + } + return itemToDomain(model), nil +} + +func (r *ItemRepository) Get(ctx context.Context, id int64) (domain.Item, error) { + var model ItemModel + err := r.db.WithContext(ctx).First(&model, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return domain.Item{}, domain.ErrNotFound + } + if err != nil { + return domain.Item{}, fmt.Errorf("get item: %w", err) + } + return itemToDomain(model), nil +} + +func (r *ItemRepository) ListByList( + ctx context.Context, listID int64, limit, offset int, +) ([]domain.Item, error) { + var models []ItemModel + err := r.db.WithContext(ctx). + Where("list_id = ?", listID). + Order(itemOrder). + Limit(limit). + Offset(offset). + Find(&models). + Error + if err != nil { + return nil, fmt.Errorf("list items: %w", err) + } + + items := make([]domain.Item, len(models)) + for i, model := range models { + items[i] = itemToDomain(model) + } + return items, nil +} + +func (r *ItemRepository) CountByList(ctx context.Context, listID int64) (int64, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&ItemModel{}). + Where("list_id = ?", listID). + Count(&count). + Error + if err != nil { + return 0, fmt.Errorf("count items: %w", err) + } + return count, nil +} + +func (r *ItemRepository) UpdateTitle(ctx context.Context, id int64, title string) error { + return r.update(ctx, id, map[string]any{"title": title}) +} + +func (r *ItemRepository) UpdateNote(ctx context.Context, id int64, note string) error { + return r.update(ctx, id, map[string]any{"note": note}) +} + +func (r *ItemRepository) UpdateQuantity(ctx context.Context, id int64, quantity float64) error { + return r.update(ctx, id, map[string]any{"quantity": quantity}) +} + +func (r *ItemRepository) UpdateUrgency(ctx context.Context, id int64, urgency domain.Urgency) error { + return r.update(ctx, id, map[string]any{"urgency": string(urgency)}) +} + +func (r *ItemRepository) Delete(ctx context.Context, id int64) error { + result := r.db.WithContext(ctx).Delete(&ItemModel{}, id) + if result.Error != nil { + return fmt.Errorf("delete item: %w", result.Error) + } + if result.RowsAffected == 0 { + return domain.ErrNotFound + } + return nil +} + +func (r *ItemRepository) SetDone(ctx context.Context, id int64) error { + now := time.Now() + return r.updateConditional( + ctx, + "id = ? AND status = ?", map[string]any{ + "status": string(domain.ItemStatusDone), + "completed_at": now, + }, + id, string(domain.ItemStatusOpen), + ) +} + +func (r *ItemRepository) SetOpen(ctx context.Context, id int64) error { + return r.updateConditional( + ctx, + "id = ? AND status = ?", map[string]any{ + "status": string(domain.ItemStatusOpen), + "completed_at": nil, + }, + id, string(domain.ItemStatusDone), + ) +} + +func (r *ItemRepository) Reserve(ctx context.Context, id, userID int64) error { + now := time.Now() + return r.updateConditional( + ctx, + "id = ? AND status = ? AND reserved_by_id IS NULL", map[string]any{ + "reserved_by_id": userID, + "reserved_at": now, + }, + id, string(domain.ItemStatusOpen), + ) +} + +func (r *ItemRepository) Release(ctx context.Context, id int64) error { + return r.updateConditional( + ctx, + "id = ? AND reserved_by_id IS NOT NULL", map[string]any{ + "reserved_by_id": nil, + "reserved_at": nil, + }, + id, + ) +} + +func (r *ItemRepository) update(ctx context.Context, id int64, updates map[string]any) error { + result := r.applyUpdate(ctx, "id = ?", []any{id}, updates) + if result.Error != nil { + return fmt.Errorf("update item: %w", result.Error) + } + if result.RowsAffected == 0 { + return domain.ErrNotFound + } + return nil +} + +func (r *ItemRepository) updateConditional( + ctx context.Context, condition string, updates map[string]any, args ...any, +) error { + result := r.applyUpdate(ctx, condition, args, updates) + if result.Error != nil { + return fmt.Errorf("update item: %w", result.Error) + } + if result.RowsAffected == 0 { + return domain.ErrConflict + } + return nil +} + +func (r *ItemRepository) applyUpdate( + ctx context.Context, condition string, args []any, updates map[string]any, +) *gorm.DB { + updates["updated_at"] = time.Now() + return r.db.WithContext(ctx). + Model(&ItemModel{}). + Where(condition, args...). + Updates(updates) +} + +func itemToDomain(model ItemModel) domain.Item { + return domain.Item{ + ID: model.ID, + ListID: model.ListID, + Title: model.Title, + Note: model.Note, + Quantity: model.Quantity, + Urgency: domain.Urgency(model.Urgency), + Status: domain.ItemStatus(model.Status), + ReservedByID: model.ReservedByID, + ReservedAt: model.ReservedAt, + CompletedAt: model.CompletedAt, + CreatedByID: model.CreatedByID, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + } +} diff --git a/internal/repository/models.go b/internal/repository/models.go index cae8659..be90c37 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -7,7 +7,7 @@ import ( ) func Migrate(db *gorm.DB) error { - return db.AutoMigrate(&UserModel{}, &ListModel{}, &ListShareModel{}) + return db.AutoMigrate(&UserModel{}, &ListModel{}, &ListShareModel{}, &ItemModel{}) } type UserModel struct { @@ -40,3 +40,21 @@ type ListShareModel struct { } func (ListShareModel) TableName() string { return "list_shares" } + +type ItemModel struct { + ID int64 `gorm:"primaryKey"` + ListID int64 `gorm:"not null;index:idx_items_list_status_urgency_created,priority:1;index:idx_items_list_created,priority:1"` + Title string `gorm:"not null;size:200"` + Note string `gorm:"size:1000"` + Quantity float64 `gorm:"type:numeric(12,3);not null;default:1"` + Urgency string `gorm:"not null;size:16;default:normal;index:idx_items_list_status_urgency_created,priority:3"` + Status string `gorm:"not null;size:16;default:open;index:idx_items_list_status_urgency_created,priority:2"` + ReservedByID *int64 `gorm:"index"` + ReservedAt *time.Time + CompletedAt *time.Time + CreatedByID int64 `gorm:"not null;index"` + CreatedAt time.Time `gorm:"index:idx_items_list_created,priority:2"` + UpdatedAt time.Time +} + +func (ItemModel) TableName() string { return "items" } diff --git a/internal/service/access_test.go b/internal/service/access_test.go index b96d216..9a2454e 100644 --- a/internal/service/access_test.go +++ b/internal/service/access_test.go @@ -49,12 +49,25 @@ func TestAuthorize(t *testing.T) { {name: "owner views", userID: 1, listID: listID, action: domain.ActionViewList}, {name: "owner renames", userID: 1, listID: listID, action: domain.ActionEditListMeta}, {name: "owner deletes", userID: 1, listID: listID, action: domain.ActionDeleteList}, + {name: "owner adds items", userID: 1, listID: listID, action: domain.ActionAddItem}, + {name: "owner reserves", userID: 1, listID: listID, action: domain.ActionReserveItem}, {name: "editor views", userID: 2, listID: listID, action: domain.ActionViewList}, + {name: "editor views items", userID: 2, listID: listID, action: domain.ActionViewItems}, + {name: "editor adds items", userID: 2, listID: listID, action: domain.ActionAddItem}, + {name: "editor edits items", userID: 2, listID: listID, action: domain.ActionEditItem}, + {name: "editor deletes items", userID: 2, listID: listID, action: domain.ActionDeleteItem}, + {name: "editor changes item state", userID: 2, listID: listID, action: domain.ActionChangeItemState}, + {name: "editor reserves", userID: 2, listID: listID, action: domain.ActionReserveItem}, {name: "editor cannot rename", userID: 2, listID: listID, action: domain.ActionEditListMeta, wantErr: domain.ErrForbidden}, {name: "editor cannot delete", userID: 2, listID: listID, action: domain.ActionDeleteList, wantErr: domain.ErrForbidden}, {name: "reader views", userID: 3, listID: listID, action: domain.ActionViewList}, + {name: "reader views items", userID: 3, listID: listID, action: domain.ActionViewItems}, + {name: "reader cannot add items", userID: 3, listID: listID, action: domain.ActionAddItem, wantErr: domain.ErrForbidden}, + {name: "reader cannot edit items", userID: 3, listID: listID, action: domain.ActionEditItem, wantErr: domain.ErrForbidden}, + {name: "reader cannot reserve", userID: 3, listID: listID, action: domain.ActionReserveItem, wantErr: domain.ErrForbidden}, {name: "reader cannot rename", userID: 3, listID: listID, action: domain.ActionEditListMeta, wantErr: domain.ErrForbidden}, {name: "stranger gets not found", userID: 4, listID: listID, action: domain.ActionViewList, wantErr: domain.ErrNotFound}, + {name: "stranger cannot view items", userID: 4, listID: listID, action: domain.ActionViewItems, wantErr: domain.ErrNotFound}, {name: "missing list", userID: 1, listID: 99, action: domain.ActionViewList, wantErr: domain.ErrNotFound}, } diff --git a/internal/service/item.go b/internal/service/item.go new file mode 100644 index 0000000..5103451 --- /dev/null +++ b/internal/service/item.go @@ -0,0 +1,250 @@ +package service + +import ( + "context" + "fmt" + "math" + "strings" + "unicode/utf8" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +const ( + ItemPageSize = 10 + maxItemTitleLength = 200 + maxItemNoteLength = 1000 +) + +type ItemRepository interface { + Create(ctx context.Context, listID, createdByID int64, title string) (domain.Item, error) + Get(ctx context.Context, id int64) (domain.Item, error) + ListByList(ctx context.Context, listID int64, limit, offset int) ([]domain.Item, error) + CountByList(ctx context.Context, listID int64) (int64, error) + UpdateTitle(ctx context.Context, id int64, title string) error + UpdateNote(ctx context.Context, id int64, note string) error + UpdateQuantity(ctx context.Context, id int64, quantity float64) error + UpdateUrgency(ctx context.Context, id int64, urgency domain.Urgency) error + Delete(ctx context.Context, id int64) error + SetDone(ctx context.Context, id int64) error + SetOpen(ctx context.Context, id int64) error + Reserve(ctx context.Context, id, userID int64) error + Release(ctx context.Context, id int64) error +} + +type ItemService struct { + items ItemRepository + access *AccessService +} + +func NewItemService(items ItemRepository, access *AccessService) *ItemService { + return &ItemService{items: items, access: access} +} + +type ItemPage struct { + Access domain.Access + Items []domain.Item + Page int + Total int +} + +func (p ItemPage) PageCount() int { + if p.Total == 0 { + return 1 + } + return (p.Total + ItemPageSize - 1) / ItemPageSize +} + +func (p ItemPage) HasPrev() bool { return p.Page > 1 } + +func (p ItemPage) HasNext() bool { return p.Page < p.PageCount() } + +type ItemAccess struct { + Item domain.Item + Access domain.Access +} + +func (s *ItemService) List( + ctx context.Context, userID, listID int64, page int, +) (ItemPage, error) { + access, err := s.access.Authorize(ctx, userID, listID, domain.ActionViewItems) + if err != nil { + return ItemPage{}, err + } + + total, err := s.items.CountByList(ctx, listID) + if err != nil { + return ItemPage{}, err + } + if page < 1 { + page = 1 + } + pageCount := (int(total) + ItemPageSize - 1) / ItemPageSize + if pageCount < 1 { + pageCount = 1 + } + if page > pageCount { + page = pageCount + } + + items, err := s.items.ListByList(ctx, listID, ItemPageSize, (page-1)*ItemPageSize) + if err != nil { + return ItemPage{}, err + } + return ItemPage{Access: access, Items: items, Page: page, Total: int(total)}, nil +} + +func (s *ItemService) Get(ctx context.Context, userID, itemID int64) (ItemAccess, error) { + return s.Authorize(ctx, userID, itemID, domain.ActionViewItems) +} + +func (s *ItemService) Authorize( + ctx context.Context, userID, itemID int64, action domain.Action, +) (ItemAccess, error) { + item, err := s.items.Get(ctx, itemID) + if err != nil { + return ItemAccess{}, err + } + access, err := s.access.Authorize(ctx, userID, item.ListID, action) + if err != nil { + return ItemAccess{}, err + } + return ItemAccess{Item: item, Access: access}, nil +} + +func (s *ItemService) Create( + ctx context.Context, userID, listID int64, title string, +) (domain.Item, error) { + if _, err := s.access.Authorize(ctx, userID, listID, domain.ActionAddItem); err != nil { + return domain.Item{}, err + } + title, err := validateItemTitle(title) + if err != nil { + return domain.Item{}, err + } + return s.items.Create(ctx, listID, userID, title) +} + +func (s *ItemService) Rename(ctx context.Context, userID, itemID int64, title string) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionEditItem); err != nil { + return err + } + title, err := validateItemTitle(title) + if err != nil { + return err + } + return s.items.UpdateTitle(ctx, itemID, title) +} + +func (s *ItemService) SetNote(ctx context.Context, userID, itemID int64, note string) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionEditItem); err != nil { + return err + } + note, err := validateItemNote(note) + if err != nil { + return err + } + return s.items.UpdateNote(ctx, itemID, note) +} + +func (s *ItemService) SetQuantity(ctx context.Context, userID, itemID int64, quantity float64) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionEditItem); err != nil { + return err + } + quantity, err := normalizeItemQuantity(quantity) + if err != nil { + return err + } + return s.items.UpdateQuantity(ctx, itemID, quantity) +} + +func (s *ItemService) SetUrgency( + ctx context.Context, userID, itemID int64, urgency domain.Urgency, +) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionEditItem); err != nil { + return err + } + if !validUrgency(urgency) { + return domain.NewValidationError("Invalid urgency.") + } + return s.items.UpdateUrgency(ctx, itemID, urgency) +} + +func (s *ItemService) Delete(ctx context.Context, userID, itemID int64) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionDeleteItem); err != nil { + return err + } + return s.items.Delete(ctx, itemID) +} + +func (s *ItemService) SetDone(ctx context.Context, userID, itemID int64) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionChangeItemState); err != nil { + return err + } + return s.items.SetDone(ctx, itemID) +} + +func (s *ItemService) Reopen(ctx context.Context, userID, itemID int64) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionChangeItemState); err != nil { + return err + } + return s.items.SetOpen(ctx, itemID) +} + +func (s *ItemService) Reserve(ctx context.Context, userID, itemID int64) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionReserveItem); err != nil { + return err + } + return s.items.Reserve(ctx, itemID, userID) +} + +func (s *ItemService) Release(ctx context.Context, userID, itemID int64) error { + if _, err := s.Authorize(ctx, userID, itemID, domain.ActionReserveItem); err != nil { + return err + } + return s.items.Release(ctx, itemID) +} + +func validateItemTitle(title string) (string, error) { + title = strings.TrimSpace(title) + if title == "" || utf8.RuneCountInString(title) > maxItemTitleLength { + return "", domain.NewValidationError( + fmt.Sprintf("Item title must be 1-%d characters.", maxItemTitleLength), + ) + } + return title, nil +} + +func validateItemNote(note string) (string, error) { + note = strings.TrimSpace(note) + if utf8.RuneCountInString(note) > maxItemNoteLength { + return "", domain.NewValidationError( + fmt.Sprintf("Note must be at most %d characters.", maxItemNoteLength), + ) + } + return note, nil +} + +// Item quantity is stored as numeric(12,3), so keep the same precision in the +// domain instead of letting the database round silently. +func normalizeItemQuantity(quantity float64) (float64, error) { + if math.IsNaN(quantity) || math.IsInf(quantity, 0) || quantity <= 0 { + return 0, domain.NewValidationError("Quantity must be a positive number.") + } + + const scale = 1000 + rounded := math.Round(quantity*scale) / scale + if math.Abs(quantity-rounded) > 1e-9 { + return 0, domain.NewValidationError("Quantity can have at most 3 decimal places.") + } + return rounded, nil +} + +func validUrgency(urgency domain.Urgency) bool { + switch urgency { + case domain.UrgencyLow, domain.UrgencyNormal, domain.UrgencyHigh: + return true + default: + return false + } +} diff --git a/internal/service/item_service_test.go b/internal/service/item_service_test.go new file mode 100644 index 0000000..4c8b7bc --- /dev/null +++ b/internal/service/item_service_test.go @@ -0,0 +1,166 @@ +package service + +import ( + "context" + "errors" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type fakeItemRepository struct { + items []domain.Item + total int64 + created domain.Item + createCalls int + lastLimit int + lastOffset int + lastReserveUser int64 +} + +func (f *fakeItemRepository) Create( + _ context.Context, _, _ int64, _ string, +) (domain.Item, error) { + f.createCalls++ + return f.created, nil +} + +func (f *fakeItemRepository) Get(_ context.Context, id int64) (domain.Item, error) { + for _, item := range f.items { + if item.ID == id { + return item, nil + } + } + return domain.Item{}, domain.ErrNotFound +} + +func (f *fakeItemRepository) ListByList( + _ context.Context, _ int64, limit, offset int, +) ([]domain.Item, error) { + f.lastLimit = limit + f.lastOffset = offset + return f.items, nil +} + +func (f *fakeItemRepository) CountByList(_ context.Context, _ int64) (int64, error) { + return f.total, nil +} + +func (f *fakeItemRepository) UpdateTitle(_ context.Context, _ int64, _ string) error { + return nil +} + +func (f *fakeItemRepository) UpdateNote(_ context.Context, _ int64, _ string) error { + return nil +} + +func (f *fakeItemRepository) UpdateQuantity(_ context.Context, _ int64, _ float64) error { + return nil +} + +func (f *fakeItemRepository) UpdateUrgency( + _ context.Context, _ int64, _ domain.Urgency, +) error { + return nil +} + +func (f *fakeItemRepository) Delete(_ context.Context, _ int64) error { return nil } + +func (f *fakeItemRepository) SetDone(_ context.Context, _ int64) error { return nil } + +func (f *fakeItemRepository) SetOpen(_ context.Context, _ int64) error { return nil } + +func (f *fakeItemRepository) Reserve(_ context.Context, _, userID int64) error { + f.lastReserveUser = userID + return nil +} + +func (f *fakeItemRepository) Release(_ context.Context, _ int64) error { return nil } + +func newTestItemService() (*ItemService, *fakeItemRepository) { + const listID = int64(10) + accessRepo := &fakeAccessRepository{ + lists: map[int64]domain.List{ + listID: {ID: listID, OwnerID: 1, Title: "Groceries"}, + }, + roles: map[[2]int64]domain.Role{ + {listID, 2}: domain.RoleEditor, + {listID, 3}: domain.RoleReader, + }, + } + itemRepo := &fakeItemRepository{ + items: []domain.Item{{ID: 5, ListID: listID, Title: "Milk"}}, + total: 25, + } + return NewItemService(itemRepo, NewAccessService(accessRepo)), itemRepo +} + +func TestItemServiceListClampsPage(t *testing.T) { + items, itemRepo := newTestItemService() + + itemPage, err := items.List(context.Background(), 3, 10, 0) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if itemPage.Page != 1 { + t.Fatalf("List() page = %d, want 1", itemPage.Page) + } + if itemRepo.lastLimit != ItemPageSize || itemRepo.lastOffset != 0 { + t.Fatalf("List() limit/offset = %d/%d", itemRepo.lastLimit, itemRepo.lastOffset) + } + + itemPage, err = items.List(context.Background(), 3, 10, 99) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if itemPage.Page != 3 { + t.Fatalf("List() page = %d, want 3", itemPage.Page) + } + if itemRepo.lastOffset != 2*ItemPageSize { + t.Fatalf("List() offset = %d, want %d", itemRepo.lastOffset, 2*ItemPageSize) + } +} + +func TestItemServiceRejectsStranger(t *testing.T) { + items, _ := newTestItemService() + + if _, err := items.List(context.Background(), 4, 10, 1); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("List() error = %v, want ErrNotFound", err) + } +} + +func TestItemServiceCreateAuthorization(t *testing.T) { + items, itemRepo := newTestItemService() + + if _, err := items.Create(context.Background(), 3, 10, "Bread"); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("Create() error = %v, want ErrForbidden", err) + } + if itemRepo.createCalls != 0 { + t.Fatalf("Create() called repository %d times", itemRepo.createCalls) + } + + if _, err := items.Create(context.Background(), 2, 10, "Bread"); err != nil { + t.Fatalf("Create() error = %v", err) + } + if itemRepo.createCalls != 1 { + t.Fatalf("Create() called repository %d times, want 1", itemRepo.createCalls) + } +} + +func TestItemServiceReserveAuthorization(t *testing.T) { + items, itemRepo := newTestItemService() + + if err := items.Reserve(context.Background(), 3, 5); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("Reserve() error = %v, want ErrForbidden", err) + } + if itemRepo.lastReserveUser != 0 { + t.Fatal("Reserve() called repository for a reader") + } + + if err := items.Reserve(context.Background(), 2, 5); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if itemRepo.lastReserveUser != 2 { + t.Fatalf("Reserve() user = %d, want 2", itemRepo.lastReserveUser) + } +} diff --git a/internal/service/item_test.go b/internal/service/item_test.go new file mode 100644 index 0000000..bc99745 --- /dev/null +++ b/internal/service/item_test.go @@ -0,0 +1,152 @@ +package service + +import ( + "math" + "strings" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +func TestValidateItemTitle(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "trims", input: " Milk ", want: "Milk"}, + {name: "empty", input: "", wantErr: true}, + {name: "spaces only", input: " ", wantErr: true}, + {name: "max length", input: strings.Repeat("a", 200), want: strings.Repeat("a", 200)}, + {name: "too long", input: strings.Repeat("a", 201), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateItemTitle(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("validateItemTitle() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("validateItemTitle() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidateItemNote(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "trims", input: " 2% milk ", want: "2% milk"}, + {name: "empty", input: "", want: ""}, + {name: "max length", input: strings.Repeat("a", 1000), want: strings.Repeat("a", 1000)}, + {name: "too long", input: strings.Repeat("a", 1001), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateItemNote(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("validateItemNote() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("validateItemNote() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidUrgency(t *testing.T) { + for _, urgency := range []domain.Urgency{ + domain.UrgencyLow, domain.UrgencyNormal, domain.UrgencyHigh, + } { + if !validUrgency(urgency) { + t.Fatalf("validUrgency(%q) = false", urgency) + } + } + if validUrgency("urgent") { + t.Fatal("validUrgency() accepted an unknown value") + } +} + +func TestItemPage(t *testing.T) { + tests := []struct { + name string + page ItemPage + wantPages int + wantPrev bool + wantNext bool + }{ + { + name: "empty", + page: ItemPage{Page: 1, Total: 0}, + wantPages: 1, + }, + { + name: "one full page", + page: ItemPage{Page: 1, Total: ItemPageSize}, + wantPages: 1, + }, + { + name: "two pages on first", + page: ItemPage{Page: 1, Total: ItemPageSize + 1}, + wantPages: 2, + wantNext: true, + }, + { + name: "two pages on last", + page: ItemPage{Page: 2, Total: ItemPageSize + 1}, + wantPages: 2, + wantPrev: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.page.PageCount(); got != tt.wantPages { + t.Fatalf("PageCount() = %d, want %d", got, tt.wantPages) + } + if got := tt.page.HasPrev(); got != tt.wantPrev { + t.Fatalf("HasPrev() = %v, want %v", got, tt.wantPrev) + } + if got := tt.page.HasNext(); got != tt.wantNext { + t.Fatalf("HasNext() = %v, want %v", got, tt.wantNext) + } + }) + } +} + +func TestNormalizeItemQuantity(t *testing.T) { + tests := []struct { + name string + input float64 + want float64 + wantErr bool + }{ + {name: "whole number", input: 2, want: 2}, + {name: "fraction", input: 1.5, want: 1.5}, + {name: "three decimals", input: 1.234, want: 1.234}, + {name: "too many decimals", input: 1.2345, wantErr: true}, + {name: "zero", input: 0, wantErr: true}, + {name: "negative", input: -1, wantErr: true}, + {name: "nan", input: math.NaN(), wantErr: true}, + {name: "infinity", input: math.Inf(1), wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeItemQuantity(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("normalizeItemQuantity() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Fatalf("normalizeItemQuantity() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/service/list.go b/internal/service/list.go index 71c6102..deef2be 100644 --- a/internal/service/list.go +++ b/internal/service/list.go @@ -9,7 +9,7 @@ import ( "git.entityseven.com/rilaxik/pllist/internal/domain" ) -const maxTitleLength = 120 +const maxListTitleLength = 120 type ListRepository interface { Create(ctx context.Context, ownerID int64, title string) (domain.List, error) @@ -30,7 +30,7 @@ func NewListService(lists ListRepository, access *AccessService) *ListService { } func (s *ListService) Create(ctx context.Context, userID int64, title string) (domain.List, error) { - title, err := validateTitle(title) + title, err := validateListTitle(title) if err != nil { return domain.List{}, err } @@ -38,7 +38,7 @@ func (s *ListService) Create(ctx context.Context, userID int64, title string) (d } func (s *ListService) Rename(ctx context.Context, userID, listID int64, title string) (domain.List, error) { - title, err := validateTitle(title) + title, err := validateListTitle(title) if err != nil { return domain.List{}, err } @@ -62,10 +62,12 @@ func (s *ListService) List(ctx context.Context, userID int64) ([]domain.List, er return s.lists.ListForUser(ctx, userID) } -func validateTitle(title string) (string, error) { +func validateListTitle(title string) (string, error) { title = strings.TrimSpace(title) - if title == "" || utf8.RuneCountInString(title) > maxTitleLength { - return "", fmt.Errorf("%w: title must be 1-%d characters", domain.ErrInvalidInput, maxTitleLength) + if title == "" || utf8.RuneCountInString(title) > maxListTitleLength { + return "", domain.NewValidationError( + fmt.Sprintf("List title must be 1-%d characters.", maxListTitleLength), + ) } return title, nil } diff --git a/internal/service/list_test.go b/internal/service/list_test.go index 359a375..b027b0f 100644 --- a/internal/service/list_test.go +++ b/internal/service/list_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -func TestValidateTitle(t *testing.T) { +func TestValidateListTitle(t *testing.T) { tests := []struct { name string input string @@ -21,12 +21,12 @@ func TestValidateTitle(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := validateTitle(tt.input) + got, err := validateListTitle(tt.input) if (err != nil) != tt.wantErr { - t.Fatalf("validateTitle() error = %v, wantErr %v", err, tt.wantErr) + t.Fatalf("validateListTitle() error = %v, wantErr %v", err, tt.wantErr) } if got != tt.want { - t.Fatalf("validateTitle() = %q, want %q", got, tt.want) + t.Fatalf("validateListTitle() = %q, want %q", got, tt.want) } }) } diff --git a/internal/service/session.go b/internal/service/session.go index 21eaac6..a3ecc55 100644 --- a/internal/service/session.go +++ b/internal/service/session.go @@ -10,11 +10,17 @@ type PendingAction string const ( PendingCreateList PendingAction = "create_list" PendingRenameList PendingAction = "rename_list" + + PendingCreateItem PendingAction = "create_item" + PendingRenameItem PendingAction = "rename_item" + PendingEditItemNote PendingAction = "edit_item_note" + PendingEditItemQuantity PendingAction = "edit_item_quantity" ) type Pending struct { Action PendingAction ListID int64 + ItemID int64 expiresAt time.Time } @@ -32,14 +38,12 @@ func NewPendingStore(ttl time.Duration) *PendingStore { } } -func (s *PendingStore) Set(telegramID int64, action PendingAction, listID int64) { +func (s *PendingStore) Set(telegramID int64, pending Pending) { + pending.expiresAt = time.Now().Add(s.ttl) + s.mu.Lock() defer s.mu.Unlock() - s.items[telegramID] = Pending{ - Action: action, - ListID: listID, - expiresAt: time.Now().Add(s.ttl), - } + s.items[telegramID] = pending } func (s *PendingStore) Take(telegramID int64) (Pending, bool) { diff --git a/internal/service/session_test.go b/internal/service/session_test.go index 42d44f4..426d411 100644 --- a/internal/service/session_test.go +++ b/internal/service/session_test.go @@ -7,7 +7,7 @@ import ( func TestPendingStore(t *testing.T) { store := NewPendingStore(time.Minute) - store.Set(1, PendingCreateList, 0) + store.Set(1, Pending{Action: PendingCreateList}) pending, ok := store.Take(1) if !ok || pending.Action != PendingCreateList { @@ -17,7 +17,7 @@ func TestPendingStore(t *testing.T) { t.Fatal("Take() returned a consumed pending value") } - store.Set(2, PendingRenameList, 10) + store.Set(2, Pending{Action: PendingRenameList, ListID: 10}) if !store.Clear(2) { t.Fatal("Clear() = false, want true") } @@ -28,7 +28,7 @@ func TestPendingStore(t *testing.T) { func TestPendingStoreExpiry(t *testing.T) { store := NewPendingStore(0) - store.Set(1, PendingCreateList, 0) + store.Set(1, Pending{Action: PendingCreateList}) if _, ok := store.Take(1); ok { t.Fatal("Take() returned an expired pending value") @@ -37,7 +37,7 @@ func TestPendingStoreExpiry(t *testing.T) { func TestPendingStoreGetDoesNotConsume(t *testing.T) { store := NewPendingStore(time.Minute) - store.Set(1, PendingRenameList, 10) + store.Set(1, Pending{Action: PendingRenameList, ListID: 10}) first, ok := store.Get(1) if !ok || first.Action != PendingRenameList || first.ListID != 10 { diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 85a1b21..1e1319a 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -9,6 +9,7 @@ import ( th "github.com/mymmrac/telego/telegohandler" "git.entityseven.com/rilaxik/pllist/internal/service" + itemtelegram "git.entityseven.com/rilaxik/pllist/internal/telegram/item" "git.entityseven.com/rilaxik/pllist/internal/telegram/list" "git.entityseven.com/rilaxik/pllist/internal/telegram/menu" "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" @@ -28,6 +29,7 @@ type Bot struct { pendingRouter *shared.PendingRouter menu *menu.Handler list *list.Handler + item *itemtelegram.Handler locker *keyedLocker } @@ -36,10 +38,12 @@ func New( updates <-chan telego.Update, users *service.UserService, lists *service.ListService, + items *service.ItemService, access *service.AccessService, pending *service.PendingStore, ) (*Bot, error) { sender := shared.NewSender(api) + itemHandler := itemtelegram.New(sender, items, access, pending) bot := &Bot{ api: api, users: users, @@ -47,7 +51,8 @@ func New( sender: sender, pendingRouter: shared.NewPendingRouter(), menu: menu.New(sender, pending), - list: list.New(sender, lists, access, pending), + list: list.New(sender, lists, access, pending, itemHandler), + item: itemHandler, locker: newKeyedLocker(), } diff --git a/internal/telegram/item/callback_data.go b/internal/telegram/item/callback_data.go new file mode 100644 index 0000000..4a33306 --- /dev/null +++ b/internal/telegram/item/callback_data.go @@ -0,0 +1,66 @@ +package item + +import ( + "fmt" + "strconv" + "strings" + + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +const ( + callbackPage = "page" + callbackAdd = "add" + callbackItem = "item" + + callbackManage = "manage" + callbackEditTitle = "edit_title" + callbackEditNote = "edit_note" + callbackEditQuantity = "edit_qty" + callbackClearNote = "clear_note" + callbackUrgencyLow = "urgency_low" + callbackUrgencyNorm = "urgency_normal" + callbackUrgencyHigh = "urgency_high" + callbackDone = "done_item" + callbackReopen = "reopen_item" + callbackReserve = "reserve_item" + callbackRelease = "release_item" + callbackDelete = "delete_item" + callbackDeleteYes = "delete_item_yes" +) + +func addData(listID int64) string { + return shared.CallbackWithID(callbackAdd, listID) +} + +func itemData(itemID int64) string { + return shared.CallbackWithID(callbackItem, itemID) +} + +func manageData(listID int64) string { + return shared.CallbackWithID(callbackManage, listID) +} + +func pageData(listID int64, page int) string { + return fmt.Sprintf("%s:%d:%d", callbackPage, listID, page) +} + +func parsePage(data string) (int64, int, error) { + raw, ok := strings.CutPrefix(data, callbackPage+":") + if !ok { + return 0, 0, shared.ErrBadCallback + } + parts := strings.Split(raw, ":") + if len(parts) != 2 { + return 0, 0, shared.ErrBadCallback + } + listID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || listID <= 0 { + return 0, 0, shared.ErrBadCallback + } + page, err := strconv.Atoi(parts[1]) + if err != nil || page < 1 { + return 0, 0, shared.ErrBadCallback + } + return listID, page, nil +} diff --git a/internal/telegram/item/callback_data_test.go b/internal/telegram/item/callback_data_test.go new file mode 100644 index 0000000..aa10891 --- /dev/null +++ b/internal/telegram/item/callback_data_test.go @@ -0,0 +1,30 @@ +package item + +import "testing" + +func TestPageDataRoundTrip(t *testing.T) { + data := pageData(42, 3) + listID, page, err := parsePage(data) + if err != nil { + t.Fatalf("parsePage(%q) error = %v", data, err) + } + if listID != 42 || page != 3 { + t.Fatalf("parsePage(%q) = %d, %d", data, listID, page) + } +} + +func TestParsePageRejectsBadData(t *testing.T) { + for _, data := range []string{ + "page", + "page:42", + "page:0:1", + "page:42:0", + "page:42:abc", + "page:42:1:extra", + "other:42:1", + } { + if _, _, err := parsePage(data); err == nil { + t.Fatalf("parsePage(%q) accepted invalid data", data) + } + } +} diff --git a/internal/telegram/item/callbacks.go b/internal/telegram/item/callbacks.go new file mode 100644 index 0000000..822ad5b --- /dev/null +++ b/internal/telegram/item/callbacks.go @@ -0,0 +1,203 @@ +package item + +import ( + "context" + "errors" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func (h *Handler) listCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := shared.CallbackID(query.Data, shared.ListOpen) + if err != nil { + return err + } + return h.ShowList(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query), listID) +} + +func (h *Handler) pageCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, page, err := parsePage(query.Data) + if err != nil { + return err + } + return h.ShowItems(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query), listID, page) +} + +func (h *Handler) manageCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := shared.CallbackID(query.Data, callbackManage) + if err != nil { + return err + } + return h.ShowItems(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query), listID, 1) +} + +func (h *Handler) addCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := shared.CallbackID(query.Data, callbackAdd) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if _, err := h.access.Authorize(ctx, user.ID, listID, domain.ActionAddItem); err != nil { + return err + } + h.pending.Set(user.TelegramID, service.Pending{ + Action: service.PendingCreateItem, + ListID: listID, + }) + return h.sender.Send(ctx, shared.QueryChatID(query), addItemPrompt, nil) +} + +func (h *Handler) itemCallback(ctx *th.Context, query telego.CallbackQuery) error { + itemID, err := shared.CallbackID(query.Data, callbackItem) + if err != nil { + return err + } + return h.showItem(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query), itemID) +} + +func (h *Handler) editTitleCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.promptItemEdit(ctx, query, callbackEditTitle, service.PendingRenameItem, renameItemPrompt) +} + +func (h *Handler) editNoteCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.promptItemEdit(ctx, query, callbackEditNote, service.PendingEditItemNote, notePrompt) +} + +func (h *Handler) editQuantityCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.promptItemEdit(ctx, query, callbackEditQuantity, service.PendingEditItemQuantity, quantityPrompt) +} + +func (h *Handler) promptItemEdit( + ctx *th.Context, + query telego.CallbackQuery, + callbackAction string, + action service.PendingAction, + prompt string, +) error { + itemID, err := shared.CallbackID(query.Data, callbackAction) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if _, err := h.items.Authorize(ctx, user.ID, itemID, domain.ActionEditItem); err != nil { + return err + } + h.pending.Set(user.TelegramID, service.Pending{ + Action: action, + ItemID: itemID, + }) + return h.sender.Send(ctx, shared.QueryChatID(query), prompt, nil) +} + +func (h *Handler) clearNoteCallback(ctx *th.Context, query telego.CallbackQuery) error { + itemID, err := shared.CallbackID(query.Data, callbackClearNote) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if err := h.items.SetNote(ctx, user.ID, itemID, ""); err != nil { + return err + } + return h.showItem(ctx, user.ID, + shared.QueryChatID(query), shared.QueryMessageID(query), itemID) +} + +func (h *Handler) lowUrgencyCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.setUrgency(ctx, query, callbackUrgencyLow, domain.UrgencyLow) +} + +func (h *Handler) normalUrgencyCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.setUrgency(ctx, query, callbackUrgencyNorm, domain.UrgencyNormal) +} + +func (h *Handler) highUrgencyCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.setUrgency(ctx, query, callbackUrgencyHigh, domain.UrgencyHigh) +} + +func (h *Handler) setUrgency( + ctx *th.Context, query telego.CallbackQuery, action string, urgency domain.Urgency, +) error { + itemID, err := shared.CallbackID(query.Data, action) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if err := h.items.SetUrgency(ctx, user.ID, itemID, urgency); err != nil { + return err + } + return h.showItem(ctx, user.ID, + shared.QueryChatID(query), shared.QueryMessageID(query), itemID) +} + +func (h *Handler) doneCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.mutateItem(ctx, query, callbackDone, h.items.SetDone) +} + +func (h *Handler) reopenCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.mutateItem(ctx, query, callbackReopen, h.items.Reopen) +} + +func (h *Handler) reserveCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.mutateItem(ctx, query, callbackReserve, h.items.Reserve) +} + +func (h *Handler) releaseCallback(ctx *th.Context, query telego.CallbackQuery) error { + return h.mutateItem(ctx, query, callbackRelease, h.items.Release) +} + +func (h *Handler) mutateItem( + ctx *th.Context, + query telego.CallbackQuery, + action string, + mutate func(context.Context, int64, int64) error, +) error { + itemID, err := shared.CallbackID(query.Data, action) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if err := mutate(ctx, user.ID, itemID); err != nil && !errors.Is(err, domain.ErrConflict) { + return err + } + return h.showItem(ctx, user.ID, + shared.QueryChatID(query), shared.QueryMessageID(query), itemID) +} + +func (h *Handler) deleteCallback(ctx *th.Context, query telego.CallbackQuery) error { + itemID, err := shared.CallbackID(query.Data, callbackDelete) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if _, err := h.items.Authorize(ctx, user.ID, itemID, domain.ActionDeleteItem); err != nil { + return err + } + return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query), + deleteItemText, deleteItemKeyboard(itemID)) +} + +func (h *Handler) deleteYesCallback(ctx *th.Context, query telego.CallbackQuery) error { + itemID, err := shared.CallbackID(query.Data, callbackDeleteYes) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + itemAccess, err := h.items.Authorize(ctx, user.ID, itemID, domain.ActionDeleteItem) + if err != nil { + return err + } + if err := h.items.Delete(ctx, user.ID, itemID); err != nil { + return err + } + return h.ShowItems(ctx, user.ID, + shared.QueryChatID(query), shared.QueryMessageID(query), itemAccess.Item.ListID, 1) +} diff --git a/internal/telegram/item/handler.go b/internal/telegram/item/handler.go new file mode 100644 index 0000000..a6cbb1d --- /dev/null +++ b/internal/telegram/item/handler.go @@ -0,0 +1,56 @@ +package item + +import ( + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +type Handler struct { + sender *shared.Sender + items *service.ItemService + access *service.AccessService + pending *service.PendingStore +} + +func New( + sender *shared.Sender, + items *service.ItemService, + access *service.AccessService, + pending *service.PendingStore, +) *Handler { + return &Handler{ + sender: sender, + items: items, + access: access, + pending: pending, + } +} + +func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) { + callbacks := router.Group(th.AnyCallbackQueryWithMessage()) + callbacks.HandleCallbackQuery(h.listCallback, th.CallbackDataPrefix(shared.ListOpen+":")) + callbacks.HandleCallbackQuery(h.pageCallback, th.CallbackDataPrefix(callbackPage+":")) + callbacks.HandleCallbackQuery(h.addCallback, th.CallbackDataPrefix(callbackAdd+":")) + callbacks.HandleCallbackQuery(h.itemCallback, th.CallbackDataPrefix(callbackItem+":")) + callbacks.HandleCallbackQuery(h.manageCallback, th.CallbackDataPrefix(callbackManage+":")) + callbacks.HandleCallbackQuery(h.editTitleCallback, th.CallbackDataPrefix(callbackEditTitle+":")) + callbacks.HandleCallbackQuery(h.editNoteCallback, th.CallbackDataPrefix(callbackEditNote+":")) + callbacks.HandleCallbackQuery(h.editQuantityCallback, th.CallbackDataPrefix(callbackEditQuantity+":")) + callbacks.HandleCallbackQuery(h.clearNoteCallback, th.CallbackDataPrefix(callbackClearNote+":")) + callbacks.HandleCallbackQuery(h.lowUrgencyCallback, th.CallbackDataPrefix(callbackUrgencyLow+":")) + callbacks.HandleCallbackQuery(h.normalUrgencyCallback, th.CallbackDataPrefix(callbackUrgencyNorm+":")) + callbacks.HandleCallbackQuery(h.highUrgencyCallback, th.CallbackDataPrefix(callbackUrgencyHigh+":")) + callbacks.HandleCallbackQuery(h.doneCallback, th.CallbackDataPrefix(callbackDone+":")) + callbacks.HandleCallbackQuery(h.reopenCallback, th.CallbackDataPrefix(callbackReopen+":")) + callbacks.HandleCallbackQuery(h.reserveCallback, th.CallbackDataPrefix(callbackReserve+":")) + callbacks.HandleCallbackQuery(h.releaseCallback, th.CallbackDataPrefix(callbackRelease+":")) + callbacks.HandleCallbackQuery(h.deleteCallback, th.CallbackDataPrefix(callbackDelete+":")) + callbacks.HandleCallbackQuery(h.deleteYesCallback, th.CallbackDataPrefix(callbackDeleteYes+":")) + + pendingRouter.Register(service.PendingCreateItem, h.createText) + pendingRouter.Register(service.PendingRenameItem, h.renameText) + pendingRouter.Register(service.PendingEditItemNote, h.noteText) + pendingRouter.Register(service.PendingEditItemQuantity, h.quantityText) +} diff --git a/internal/telegram/item/keyboards.go b/internal/telegram/item/keyboards.go new file mode 100644 index 0000000..b4d9abf --- /dev/null +++ b/internal/telegram/item/keyboards.go @@ -0,0 +1,142 @@ +package item + +import ( + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func listMenuKeyboard(itemPage service.ItemPage) *telego.InlineKeyboardMarkup { + access := itemPage.Access + rows := make([][]telego.InlineKeyboardButton, 0, 5) + + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Manage items", manageData(access.List.ID)), + )) + if domain.Allowed(access.Role, domain.ActionAddItem) { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Add item", addData(access.List.ID)), + )) + } + if access.Role == domain.RoleOwner { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Rename list", shared.RenameListData(access.List.ID)), + shared.Button("Delete list", shared.DeleteListData(access.List.ID)), + )) + } + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("My lists", shared.NavLists), + )) + return tu.InlineKeyboard(rows...) +} + +func itemsKeyboard(itemPage service.ItemPage, userID int64) *telego.InlineKeyboardMarkup { + rows := make([][]telego.InlineKeyboardButton, 0, len(itemPage.Items)+3) + for _, item := range itemPage.Items { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button(itemLabel(item, userID), itemData(item.ID)), + )) + } + + if itemPage.PageCount() > 1 { + navigation := make([]telego.InlineKeyboardButton, 0, 2) + if itemPage.HasPrev() { + navigation = append(navigation, shared.Button( + "Prev", pageData(itemPage.Access.List.ID, itemPage.Page-1), + )) + } + if itemPage.HasNext() { + navigation = append(navigation, shared.Button( + "Next", pageData(itemPage.Access.List.ID, itemPage.Page+1), + )) + } + rows = append(rows, tu.InlineKeyboardRow(navigation...)) + } + + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Back", shared.ListData(itemPage.Access.List.ID)), + )) + return tu.InlineKeyboard(rows...) +} + +func itemKeyboard(itemAccess service.ItemAccess) *telego.InlineKeyboardMarkup { + item := itemAccess.Item + role := itemAccess.Access.Role + rows := make([][]telego.InlineKeyboardButton, 0, 6) + + if domain.Allowed(role, domain.ActionReserveItem) && item.Status == domain.ItemStatusOpen { + if item.Reserved() { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Release", shared.CallbackWithID(callbackRelease, item.ID)), + )) + } else { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Reserve", shared.CallbackWithID(callbackReserve, item.ID)), + )) + } + } + + if domain.Allowed(role, domain.ActionChangeItemState) { + if item.Done() { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Reopen", shared.CallbackWithID(callbackReopen, item.ID)), + )) + } else { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Mark done", shared.CallbackWithID(callbackDone, item.ID)), + )) + } + } + + if domain.Allowed(role, domain.ActionEditItem) { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Edit title", shared.CallbackWithID(callbackEditTitle, item.ID)), + shared.Button("Edit note", shared.CallbackWithID(callbackEditNote, item.ID)), + shared.Button("Quantity", shared.CallbackWithID(callbackEditQuantity, item.ID)), + )) + if item.Note != "" { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Clear note", shared.CallbackWithID(callbackClearNote, item.ID)), + )) + } + rows = append(rows, tu.InlineKeyboardRow( + shared.Button(urgencyLabel("Low", domain.UrgencyLow, item.Urgency), + shared.CallbackWithID(callbackUrgencyLow, item.ID)), + shared.Button(urgencyLabel("Normal", domain.UrgencyNormal, item.Urgency), + shared.CallbackWithID(callbackUrgencyNorm, item.ID)), + shared.Button(urgencyLabel("High", domain.UrgencyHigh, item.Urgency), + shared.CallbackWithID(callbackUrgencyHigh, item.ID)), + )) + } + + if domain.Allowed(role, domain.ActionDeleteItem) { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Delete item", shared.CallbackWithID(callbackDelete, item.ID)), + )) + } + + rows = append(rows, tu.InlineKeyboardRow( + shared.Button("Back to items", manageData(item.ListID)), + )) + return tu.InlineKeyboard(rows...) +} + +func deleteItemKeyboard(itemID int64) *telego.InlineKeyboardMarkup { + return tu.InlineKeyboard( + tu.InlineKeyboardRow( + shared.Button("Yes, delete", shared.CallbackWithID(callbackDeleteYes, itemID)), + shared.Button("Cancel", shared.CallbackWithID(callbackItem, itemID)), + ), + ) +} + +func urgencyLabel(label string, urgency, current domain.Urgency) string { + text := urgencyEmoji(urgency) + " " + label + if urgency == current { + return "• " + text + } + return text +} diff --git a/internal/telegram/item/keyboards_test.go b/internal/telegram/item/keyboards_test.go new file mode 100644 index 0000000..4ec19b7 --- /dev/null +++ b/internal/telegram/item/keyboards_test.go @@ -0,0 +1,78 @@ +package item + +import ( + "slices" + "testing" + + "github.com/mymmrac/telego" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +func TestListMenuKeyboard(t *testing.T) { + tests := []struct { + name string + role domain.Role + want []string + }{ + { + name: "owner", + role: domain.RoleOwner, + want: []string{"Manage items", "Add item", "Rename list", "Delete list", "My lists"}, + }, + { + name: "editor", + role: domain.RoleEditor, + want: []string{"Manage items", "Add item", "My lists"}, + }, + { + name: "reader", + role: domain.RoleReader, + want: []string{"Manage items", "My lists"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyboard := listMenuKeyboard(service.ItemPage{ + Access: domain.Access{ + List: domain.List{ID: 7}, + Role: tt.role, + }, + }) + if got := buttonTexts(keyboard); !slices.Equal(got, tt.want) { + t.Fatalf("listMenuKeyboard() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestItemsKeyboardHasPaginationAndBack(t *testing.T) { + keyboard := itemsKeyboard(service.ItemPage{ + Access: domain.Access{ + List: domain.List{ID: 7}, + Role: domain.RoleOwner, + }, + Items: []domain.Item{ + {ID: 1, Title: "Milk", Quantity: 2, Urgency: domain.UrgencyNormal}, + }, + Page: 1, + Total: service.ItemPageSize + 1, + }, 1) + + want := []string{"🟡 Milk x2", "Next", "Back"} + if got := buttonTexts(keyboard); !slices.Equal(got, want) { + t.Fatalf("itemsKeyboard() = %v, want %v", got, want) + } +} + +func buttonTexts(keyboard *telego.InlineKeyboardMarkup) []string { + var texts []string + for _, row := range keyboard.InlineKeyboard { + for _, button := range row { + texts = append(texts, button.Text) + } + } + return texts +} diff --git a/internal/telegram/item/pending.go b/internal/telegram/item/pending.go new file mode 100644 index 0000000..aa03d5c --- /dev/null +++ b/internal/telegram/item/pending.go @@ -0,0 +1,67 @@ +package item + +import ( + "errors" + "strconv" + "strings" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +func (h *Handler) createText( + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, +) error { + item, err := h.items.Create(ctx, user.ID, pending.ListID, message.Text) + if err != nil { + if errors.Is(err, domain.ErrInvalidInput) { + h.pending.Set(user.TelegramID, pending) + } + return err + } + return h.showItem(ctx, user.ID, message.Chat.ID, 0, item.ID) +} + +func (h *Handler) renameText( + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, +) error { + if err := h.items.Rename(ctx, user.ID, pending.ItemID, message.Text); err != nil { + if errors.Is(err, domain.ErrInvalidInput) { + h.pending.Set(user.TelegramID, pending) + } + return err + } + return h.showItem(ctx, user.ID, message.Chat.ID, 0, pending.ItemID) +} + +func (h *Handler) noteText( + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, +) error { + if err := h.items.SetNote(ctx, user.ID, pending.ItemID, message.Text); err != nil { + if errors.Is(err, domain.ErrInvalidInput) { + h.pending.Set(user.TelegramID, pending) + } + return err + } + return h.showItem(ctx, user.ID, message.Chat.ID, 0, pending.ItemID) +} + +func (h *Handler) quantityText( + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, +) error { + quantity, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(message.Text), ",", "."), 64) + if err != nil { + h.pending.Set(user.TelegramID, pending) + return domain.NewValidationError("Quantity must be a positive number.") + } + if err := h.items.SetQuantity(ctx, user.ID, pending.ItemID, quantity); err != nil { + if errors.Is(err, domain.ErrInvalidInput) { + h.pending.Set(user.TelegramID, pending) + } + return err + } + return h.showItem(ctx, user.ID, message.Chat.ID, 0, pending.ItemID) +} diff --git a/internal/telegram/item/text.go b/internal/telegram/item/text.go new file mode 100644 index 0000000..b5b477c --- /dev/null +++ b/internal/telegram/item/text.go @@ -0,0 +1,122 @@ +package item + +import ( + "fmt" + "strconv" + "strings" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +const ( + addItemPrompt = "Send the item title (1-200 characters). /cancel to stop." + renameItemPrompt = "Send the new item title (1-200 characters). /cancel to stop." + notePrompt = "Send the item note (up to 1000 characters). /cancel to stop." + quantityPrompt = "Send the quantity (positive number, e.g. 1.5). /cancel to stop." + deleteItemText = "Delete this item? This cannot be undone." + itemTruncateLength = 60 +) + +func listMenuText(itemPage service.ItemPage) string { + var text strings.Builder + fmt.Fprintf(&text, "%s\nRole: %s\n\n", + shared.Escape(itemPage.Access.List.Title), itemPage.Access.Role) + writeItemsText(&text, itemPage) + if itemPage.Total > len(itemPage.Items) { + fmt.Fprintf(&text, "\n\nShowing %d of %d. Open Manage items for the rest.", + len(itemPage.Items), itemPage.Total) + } + return text.String() +} + +func itemsText(itemPage service.ItemPage) string { + var text strings.Builder + fmt.Fprintf(&text, "%s\n\n", shared.Escape(itemPage.Access.List.Title)) + writeItemsText(&text, itemPage) + if itemPage.Total > 0 { + fmt.Fprintf(&text, "\n\nPage %d/%d\nTap an item to manage it.", + itemPage.Page, itemPage.PageCount()) + } + return text.String() +} + +func writeItemsText(text *strings.Builder, itemPage service.ItemPage) { + text.WriteString("Items:\n") + if itemPage.Total == 0 { + text.WriteString("No items yet.\n\n0 items") + return + } + for _, item := range itemPage.Items { + fmt.Fprintf(text, "%s %s\n", itemIcon(item), shared.Escape(titleWithQuantity(item))) + } + fmt.Fprintf(text, "\n%s", itemCountLabel(itemPage.Total)) +} + +func itemCountLabel(total int) string { + label := "items" + if total == 1 { + label = "item" + } + return fmt.Sprintf("%d %s", total, label) +} + +func itemText(itemAccess service.ItemAccess, userID int64) string { + item := itemAccess.Item + var text strings.Builder + fmt.Fprintf(&text, "%s %s\nStatus: %s\n", + itemIcon(item), shared.Escape(titleWithQuantity(item)), item.Status) + if item.Note != "" { + fmt.Fprintf(&text, "Note: %s\n", shared.Escape(item.Note)) + } + + switch { + case !item.Reserved(): + text.WriteString("Reserved: no") + case item.ReservedByID != nil && *item.ReservedByID == userID: + text.WriteString("Reserved: yes (you)") + default: + text.WriteString("Reserved: yes") + } + return text.String() +} + +func itemLabel(item domain.Item, userID int64) string { + title := shared.Truncate(item.Title, 32) + label := fmt.Sprintf("%s %s x%s", itemIcon(item), title, formatQuantity(item.Quantity)) + if item.Reserved() { + if item.ReservedByID != nil && *item.ReservedByID == userID { + label += " · yours" + } else { + label += " · reserved" + } + } + return shared.Truncate(label, itemTruncateLength) +} + +func itemIcon(item domain.Item) string { + if item.Done() { + return "✓" + } + return urgencyEmoji(item.Urgency) +} + +func titleWithQuantity(item domain.Item) string { + return fmt.Sprintf("%s x%s", item.Title, formatQuantity(item.Quantity)) +} + +func formatQuantity(quantity float64) string { + return strconv.FormatFloat(quantity, 'f', -1, 64) +} + +func urgencyEmoji(urgency domain.Urgency) string { + switch urgency { + case domain.UrgencyHigh: + return "🔴" + case domain.UrgencyLow: + return "🟢" + default: + return "🟡" + } +} diff --git a/internal/telegram/item/text_test.go b/internal/telegram/item/text_test.go new file mode 100644 index 0000000..3239a4f --- /dev/null +++ b/internal/telegram/item/text_test.go @@ -0,0 +1,189 @@ +package item + +import ( + "strings" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +func TestItemTextUsesUrgencyIconAndQuantityInTitle(t *testing.T) { + itemAccess := service.ItemAccess{ + Item: domain.Item{ + Title: "Milk", + Quantity: 2, + Urgency: domain.UrgencyHigh, + Status: domain.ItemStatusOpen, + }, + } + + text := itemText(itemAccess, 1) + for _, want := range []string{"🔴 Milk x2", "Status: open", "Reserved: no"} { + if !strings.Contains(text, want) { + t.Fatalf("itemText() = %q, missing %q", text, want) + } + } + for _, unwanted := range []string{"Role:", "Urgency:", "Quantity:"} { + if strings.Contains(text, unwanted) { + t.Fatalf("itemText() = %q, contains %q", text, unwanted) + } + } +} + +func TestItemTextUsesDoneIcon(t *testing.T) { + itemAccess := service.ItemAccess{ + Item: domain.Item{ + Title: "Milk", + Quantity: 1, + Urgency: domain.UrgencyLow, + Status: domain.ItemStatusDone, + }, + } + + text := itemText(itemAccess, 1) + if !strings.Contains(text, "✓ Milk") { + t.Fatalf("itemText() = %q, want done icon", text) + } + if !strings.Contains(text, "Status: done") { + t.Fatalf("itemText() = %q, want done status", text) + } +} + +func TestItemLabel(t *testing.T) { + tests := []struct { + name string + item domain.Item + user int64 + want string + }{ + { + name: "normal urgency with quantity", + item: domain.Item{Title: "Milk", Quantity: 3, Urgency: domain.UrgencyNormal}, + user: 1, + want: "🟡 Milk x3", + }, + { + name: "fractional quantity", + item: domain.Item{Title: "Cheese", Quantity: 1.5, Urgency: domain.UrgencyNormal}, + user: 1, + want: "🟡 Cheese x1.5", + }, + { + name: "single quantity still shows suffix", + item: domain.Item{Title: "Bread", Quantity: 1, Urgency: domain.UrgencyLow}, + user: 1, + want: "🟢 Bread x1", + }, + { + name: "done item", + item: domain.Item{Title: "Eggs", Quantity: 1, Urgency: domain.UrgencyHigh, Status: domain.ItemStatusDone}, + user: 1, + want: "✓ Eggs x1", + }, + { + name: "reserved by viewer", + item: domain.Item{ + Title: "Butter", + Quantity: 2, + Urgency: domain.UrgencyNormal, + ReservedByID: int64Ptr(1), + }, + user: 1, + want: "🟡 Butter x2 · yours", + }, + { + name: "reserved by someone else", + item: domain.Item{ + Title: "Butter", + Quantity: 2, + Urgency: domain.UrgencyNormal, + ReservedByID: int64Ptr(2), + }, + user: 1, + want: "🟡 Butter x2 · reserved", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := itemLabel(tt.item, tt.user); got != tt.want { + t.Fatalf("itemLabel() = %q, want %q", got, tt.want) + } + }) + } +} + +func int64Ptr(value int64) *int64 { return &value } + +func TestListMenuTextShowsItems(t *testing.T) { + text := listMenuText(service.ItemPage{ + Access: domain.Access{ + List: domain.List{Title: "Groceries"}, + Role: domain.RoleOwner, + }, + Items: []domain.Item{ + {Title: "Milk", Quantity: 2, Urgency: domain.UrgencyHigh}, + {Title: "Bread", Quantity: 1, Urgency: domain.UrgencyLow}, + }, + Total: 2, + }) + + for _, want := range []string{ + "Groceries", + "Role: owner", + "Items:", + "🔴 Milk x2", + "🟢 Bread x1", + "2 items", + } { + if !strings.Contains(text, want) { + t.Fatalf("listMenuText() = %q, missing %q", text, want) + } + } +} + +func TestListMenuTextNotesHiddenItems(t *testing.T) { + text := listMenuText(service.ItemPage{ + Access: domain.Access{ + List: domain.List{Title: "Groceries"}, + Role: domain.RoleReader, + }, + Items: []domain.Item{ + {Title: "Milk", Quantity: 1, Urgency: domain.UrgencyNormal}, + }, + Total: service.ItemPageSize + 1, + }) + + for _, want := range []string{"11 items", "Showing 1 of 11", "Manage items"} { + if !strings.Contains(text, want) { + t.Fatalf("listMenuText() = %q, missing %q", text, want) + } + } +} + +func TestItemsTextShowsItemsAndPagination(t *testing.T) { + text := itemsText(service.ItemPage{ + Access: domain.Access{ + List: domain.List{Title: "Groceries"}, + Role: domain.RoleEditor, + }, + Items: []domain.Item{ + {Title: "Milk", Quantity: 2, Urgency: domain.UrgencyHigh}, + }, + Page: 2, + Total: service.ItemPageSize + 1, + }) + + for _, want := range []string{ + "Items:", + "🔴 Milk x2", + "11 items", + "Page 2/2", + "Tap an item to manage it.", + } { + if !strings.Contains(text, want) { + t.Fatalf("itemsText() = %q, missing %q", text, want) + } + } +} diff --git a/internal/telegram/item/views.go b/internal/telegram/item/views.go new file mode 100644 index 0000000..56c7869 --- /dev/null +++ b/internal/telegram/item/views.go @@ -0,0 +1,36 @@ +package item + +import "context" + +func (h *Handler) ShowList( + ctx context.Context, userID, chatID int64, messageID int, listID int64, +) error { + itemPage, err := h.items.List(ctx, userID, listID, 1) + if err != nil { + return err + } + return h.sender.EditOrSend(ctx, chatID, messageID, + listMenuText(itemPage), listMenuKeyboard(itemPage)) +} + +func (h *Handler) ShowItems( + ctx context.Context, userID, chatID int64, messageID int, listID int64, page int, +) error { + itemPage, err := h.items.List(ctx, userID, listID, page) + if err != nil { + return err + } + return h.sender.EditOrSend(ctx, chatID, messageID, + itemsText(itemPage), itemsKeyboard(itemPage, userID)) +} + +func (h *Handler) showItem( + ctx context.Context, userID, chatID int64, messageID int, itemID int64, +) error { + itemAccess, err := h.items.Get(ctx, userID, itemID) + if err != nil { + return err + } + return h.sender.EditOrSend(ctx, chatID, messageID, + itemText(itemAccess, userID), itemKeyboard(itemAccess)) +} diff --git a/internal/telegram/list/callback_data.go b/internal/telegram/list/callback_data.go deleted file mode 100644 index 96eb111..0000000 --- a/internal/telegram/list/callback_data.go +++ /dev/null @@ -1,8 +0,0 @@ -package list - -const ( - callbackList = "list" - callbackRename = "rename" - callbackDelete = "delete" - callbackDeleteYes = "delete_yes" -) diff --git a/internal/telegram/list/callback_data_test.go b/internal/telegram/list/callback_data_test.go deleted file mode 100644 index 912903e..0000000 --- a/internal/telegram/list/callback_data_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package list - -import ( - "testing" - - "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" -) - -func TestListCallbackDataRoundTrip(t *testing.T) { - for _, action := range []string{callbackList, callbackRename, callbackDelete, callbackDeleteYes} { - data := shared.CallbackWithID(action, 42) - got, err := shared.CallbackID(data, action) - if err != nil { - t.Fatalf("CallbackID(%q) error = %v", data, err) - } - if got != 42 { - t.Fatalf("CallbackID(%q) = %d, want 42", data, got) - } - } -} diff --git a/internal/telegram/list/callbacks.go b/internal/telegram/list/callbacks.go index bebfed7..37dd331 100644 --- a/internal/telegram/list/callbacks.go +++ b/internal/telegram/list/callbacks.go @@ -18,29 +18,23 @@ func (h *Handler) newCallback(ctx *th.Context, query telego.CallbackQuery) error return h.startCreateList(ctx, shared.QueryChatID(query), shared.UserFrom(ctx).TelegramID) } -func (h *Handler) listCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, callbackList) - if err != nil { - return err - } - return h.showList(ctx, shared.UserFrom(ctx).ID, - shared.QueryChatID(query), shared.QueryMessageID(query), listID) -} - func (h *Handler) renameCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, callbackRename) + listID, err := shared.CallbackID(query.Data, shared.ListRename) if err != nil { return err } if _, err := h.access.Authorize(ctx, shared.UserFrom(ctx).ID, listID, domain.ActionEditListMeta); err != nil { return err } - h.pending.Set(shared.UserFrom(ctx).TelegramID, service.PendingRenameList, listID) + h.pending.Set(shared.UserFrom(ctx).TelegramID, service.Pending{ + Action: service.PendingRenameList, + ListID: listID, + }) return h.sender.Send(ctx, shared.QueryChatID(query), renameTitlePrompt, nil) } func (h *Handler) deleteCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, callbackDelete) + listID, err := shared.CallbackID(query.Data, shared.ListDelete) if err != nil { return err } @@ -52,7 +46,7 @@ func (h *Handler) deleteCallback(ctx *th.Context, query telego.CallbackQuery) er } func (h *Handler) deleteYesCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, callbackDeleteYes) + listID, err := shared.CallbackID(query.Data, shared.ListDeleteYes) if err != nil { return err } diff --git a/internal/telegram/list/commands.go b/internal/telegram/list/commands.go index 540c706..0253a90 100644 --- a/internal/telegram/list/commands.go +++ b/internal/telegram/list/commands.go @@ -17,6 +17,6 @@ func (h *Handler) handleNew(ctx *th.Context, message telego.Message) error { } func (h *Handler) startCreateList(ctx *th.Context, chatID, telegramID int64) error { - h.pending.Set(telegramID, service.PendingCreateList, 0) + h.pending.Set(telegramID, service.Pending{Action: service.PendingCreateList}) return h.sender.Send(ctx, chatID, titlePrompt, nil) } diff --git a/internal/telegram/list/handler.go b/internal/telegram/list/handler.go index c1b3073..15ea669 100644 --- a/internal/telegram/list/handler.go +++ b/internal/telegram/list/handler.go @@ -4,6 +4,7 @@ import ( th "github.com/mymmrac/telego/telegohandler" "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/item" "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" ) @@ -12,6 +13,7 @@ type Handler struct { lists *service.ListService access *service.AccessService pending *service.PendingStore + items *item.Handler } func New( @@ -19,12 +21,14 @@ func New( lists *service.ListService, access *service.AccessService, pending *service.PendingStore, + items *item.Handler, ) *Handler { return &Handler{ sender: sender, lists: lists, access: access, pending: pending, + items: items, } } @@ -35,10 +39,9 @@ func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingR callbacks := router.Group(th.AnyCallbackQueryWithMessage()) callbacks.HandleCallbackQuery(h.listsCallback, th.CallbackDataEqual(shared.NavLists)) callbacks.HandleCallbackQuery(h.newCallback, th.CallbackDataEqual(shared.NavNew)) - callbacks.HandleCallbackQuery(h.listCallback, th.CallbackDataPrefix(callbackList+":")) - callbacks.HandleCallbackQuery(h.renameCallback, th.CallbackDataPrefix(callbackRename+":")) - callbacks.HandleCallbackQuery(h.deleteCallback, th.CallbackDataPrefix(callbackDelete+":")) - callbacks.HandleCallbackQuery(h.deleteYesCallback, th.CallbackDataPrefix(callbackDeleteYes+":")) + callbacks.HandleCallbackQuery(h.renameCallback, th.CallbackDataPrefix(shared.ListRename+":")) + callbacks.HandleCallbackQuery(h.deleteCallback, th.CallbackDataPrefix(shared.ListDelete+":")) + callbacks.HandleCallbackQuery(h.deleteYesCallback, th.CallbackDataPrefix(shared.ListDeleteYes+":")) pendingRouter.Register(service.PendingCreateList, h.createText) pendingRouter.Register(service.PendingRenameList, h.renameText) diff --git a/internal/telegram/list/keyboards.go b/internal/telegram/list/keyboards.go index e02b661..bc4ec68 100644 --- a/internal/telegram/list/keyboards.go +++ b/internal/telegram/list/keyboards.go @@ -12,8 +12,7 @@ func listsKeyboard(lists []domain.List) *telego.InlineKeyboardMarkup { rows := make([][]telego.InlineKeyboardButton, 0, len(lists)+1) for _, list := range lists { rows = append(rows, tu.InlineKeyboardRow( - shared.Button(shared.Truncate(list.Title, 40), - shared.CallbackWithID(callbackList, list.ID)), + shared.Button(shared.Truncate(list.Title, 40), shared.ListData(list.ID)), )) } rows = append(rows, tu.InlineKeyboardRow( @@ -23,25 +22,11 @@ func listsKeyboard(lists []domain.List) *telego.InlineKeyboardMarkup { return tu.InlineKeyboard(rows...) } -func listKeyboard(access domain.Access) *telego.InlineKeyboardMarkup { - rows := make([][]telego.InlineKeyboardButton, 0, 2) - if access.Role == domain.RoleOwner { - rows = append(rows, tu.InlineKeyboardRow( - shared.Button("Rename", shared.CallbackWithID(callbackRename, access.List.ID)), - shared.Button("Delete", shared.CallbackWithID(callbackDelete, access.List.ID)), - )) - } - rows = append(rows, tu.InlineKeyboardRow( - shared.Button("Back to lists", shared.NavLists), - )) - return tu.InlineKeyboard(rows...) -} - func deleteKeyboard(listID int64) *telego.InlineKeyboardMarkup { return tu.InlineKeyboard( tu.InlineKeyboardRow( - shared.Button("Yes, delete", shared.CallbackWithID(callbackDeleteYes, listID)), - shared.Button("Cancel", shared.CallbackWithID(callbackList, listID)), + shared.Button("Yes, delete", shared.DeleteListYesData(listID)), + shared.Button("Cancel", shared.ListData(listID)), ), ) } diff --git a/internal/telegram/list/pending.go b/internal/telegram/list/pending.go index 74feca0..e671f9a 100644 --- a/internal/telegram/list/pending.go +++ b/internal/telegram/list/pending.go @@ -11,14 +11,16 @@ import ( ) func (h *Handler) createText( - ctx *th.Context, user domain.User, _ service.Pending, message telego.Message, + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, ) error { list, err := h.lists.Create(ctx, user.ID, message.Text) if err != nil { - h.pending.Set(user.TelegramID, service.PendingCreateList, 0) + if errors.Is(err, domain.ErrInvalidInput) { + h.pending.Set(user.TelegramID, pending) + } return err } - return h.showList(ctx, user.ID, message.Chat.ID, 0, list.ID) + return h.items.ShowList(ctx, user.ID, message.Chat.ID, 0, list.ID) } func (h *Handler) renameText( @@ -27,9 +29,9 @@ func (h *Handler) renameText( list, err := h.lists.Rename(ctx, user.ID, pending.ListID, message.Text) if err != nil { if errors.Is(err, domain.ErrInvalidInput) { - h.pending.Set(user.TelegramID, service.PendingRenameList, pending.ListID) + h.pending.Set(user.TelegramID, pending) } return err } - return h.showList(ctx, user.ID, message.Chat.ID, 0, list.ID) + return h.items.ShowList(ctx, user.ID, message.Chat.ID, 0, list.ID) } diff --git a/internal/telegram/list/text.go b/internal/telegram/list/text.go index f072120..1dfec77 100644 --- a/internal/telegram/list/text.go +++ b/internal/telegram/list/text.go @@ -30,8 +30,3 @@ func listsText(lists []domain.List, userID int64) string { } return text.String() } - -func listText(access domain.Access) string { - return fmt.Sprintf("%s\nRole: %s\n\nNo items yet.", - shared.Escape(access.List.Title), access.Role) -} diff --git a/internal/telegram/list/views.go b/internal/telegram/list/views.go index 29d6235..61f86c7 100644 --- a/internal/telegram/list/views.go +++ b/internal/telegram/list/views.go @@ -1,10 +1,6 @@ package list -import ( - "context" - - "git.entityseven.com/rilaxik/pllist/internal/domain" -) +import "context" func (h *Handler) showLists(ctx context.Context, userID, chatID int64, messageID int) error { lists, err := h.lists.List(ctx, userID) @@ -13,11 +9,3 @@ func (h *Handler) showLists(ctx context.Context, userID, chatID int64, messageID } return h.sender.EditOrSend(ctx, chatID, messageID, listsText(lists, userID), listsKeyboard(lists)) } - -func (h *Handler) showList(ctx context.Context, userID, chatID int64, messageID int, listID int64) error { - access, err := h.access.Authorize(ctx, userID, listID, domain.ActionViewList) - if err != nil { - return err - } - return h.sender.EditOrSend(ctx, chatID, messageID, listText(access), listKeyboard(access)) -} diff --git a/internal/telegram/menu/text.go b/internal/telegram/menu/text.go index 69ed8d6..5d346cb 100644 --- a/internal/telegram/menu/text.go +++ b/internal/telegram/menu/text.go @@ -11,5 +11,5 @@ Use the buttons below or /help.` /cancel - cancel pending input /help - this message -Open a list to rename or delete it.` +Open a list to add items, reserve them, mark them done, and manage the list.` ) diff --git a/internal/telegram/routes.go b/internal/telegram/routes.go index b47cbea..0defeae 100644 --- a/internal/telegram/routes.go +++ b/internal/telegram/routes.go @@ -5,6 +5,7 @@ import th "github.com/mymmrac/telego/telegohandler" func (b *Bot) register() { b.menu.Register(b.handler) b.list.Register(b.handler, b.pendingRouter) + b.item.Register(b.handler, b.pendingRouter) // Catch-alls go last so feature routes match first. b.handler.HandleMessage(b.handleUnknownCommand, th.AnyCommand()) diff --git a/internal/telegram/shared/callback.go b/internal/telegram/shared/callback.go index d6d09de..a8dbf08 100644 --- a/internal/telegram/shared/callback.go +++ b/internal/telegram/shared/callback.go @@ -19,6 +19,31 @@ const ( NavHelp = "help" ) +// List callbacks are shared because the item package renders list detail +// screens while the list package handles list metadata actions. +const ( + ListOpen = "list" + ListRename = "rename_list" + ListDelete = "delete_list" + ListDeleteYes = "delete_list_yes" +) + +func ListData(id int64) string { + return CallbackWithID(ListOpen, id) +} + +func RenameListData(id int64) string { + return CallbackWithID(ListRename, id) +} + +func DeleteListData(id int64) string { + return CallbackWithID(ListDelete, id) +} + +func DeleteListYesData(id int64) string { + return CallbackWithID(ListDeleteYes, id) +} + func CallbackWithID(action string, id int64) string { return fmt.Sprintf("%s:%d", action, id) } diff --git a/internal/telegram/shared/callback_test.go b/internal/telegram/shared/callback_test.go index ac892c8..c00d73b 100644 --- a/internal/telegram/shared/callback_test.go +++ b/internal/telegram/shared/callback_test.go @@ -31,10 +31,35 @@ func TestCallbackID(t *testing.T) { } func TestCallbackWithIDRoundTrip(t *testing.T) { - for _, action := range []string{NavLists, NavNew, NavHelp} { + for _, action := range []string{NavLists, NavNew, NavHelp, ListOpen, ListRename, ListDelete, ListDeleteYes} { data := CallbackWithID(action, 7) if data != action+":7" { t.Fatalf("CallbackWithID() = %q", data) } } } + +func TestListDataRoundTrip(t *testing.T) { + tests := []struct { + name string + data string + action string + }{ + {name: "open", data: ListData(42), action: ListOpen}, + {name: "rename", data: RenameListData(42), action: ListRename}, + {name: "delete", data: DeleteListData(42), action: ListDelete}, + {name: "delete yes", data: DeleteListYesData(42), action: ListDeleteYes}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := CallbackID(tt.data, tt.action) + if err != nil { + t.Fatalf("CallbackID(%q) error = %v", tt.data, err) + } + if id != 42 { + t.Fatalf("CallbackID(%q) = %d, want 42", tt.data, id) + } + }) + } +} diff --git a/internal/telegram/shared/text.go b/internal/telegram/shared/text.go index fc00ced..c43942f 100644 --- a/internal/telegram/shared/text.go +++ b/internal/telegram/shared/text.go @@ -7,13 +7,20 @@ import ( ) func UserMessage(err error) string { + var validation domain.ValidationError + if errors.As(err, &validation) { + return validation.Message + } + switch { case errors.Is(err, domain.ErrNotFound): - return "List not found or no access." + return "Not found or no access." case errors.Is(err, domain.ErrForbidden): return "You cannot do that." + case errors.Is(err, domain.ErrConflict): + return "This changed while you were looking. Try again." case errors.Is(err, domain.ErrInvalidInput): - return "Title must be 1-120 characters." + return "Invalid input." case errors.Is(err, ErrBadCallback): return "This button is out of date. Use /lists." default: diff --git a/internal/telegram/shared/text_test.go b/internal/telegram/shared/text_test.go new file mode 100644 index 0000000..528a1ea --- /dev/null +++ b/internal/telegram/shared/text_test.go @@ -0,0 +1,31 @@ +package shared + +import ( + "errors" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +func TestUserMessage(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {name: "validation", err: domain.NewValidationError("Title is too long."), want: "Title is too long."}, + {name: "wrapped validation", err: errors.Join(errors.New("context"), domain.NewValidationError("Bad quantity.")), want: "Bad quantity."}, + {name: "not found", err: domain.ErrNotFound, want: "Not found or no access."}, + {name: "forbidden", err: domain.ErrForbidden, want: "You cannot do that."}, + {name: "conflict", err: domain.ErrConflict, want: "This changed while you were looking. Try again."}, + {name: "bad callback", err: ErrBadCallback, want: "This button is out of date. Use /lists."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := UserMessage(tt.err); got != tt.want { + t.Fatalf("UserMessage() = %q, want %q", got, tt.want) + } + }) + } +} -- 2.54.0 From 24f35c4348d219b34d8b41e99f4ccfb1eb62d0c1 Mon Sep 17 00:00:00 2001 From: Danya H Date: Sat, 7 Nov 2026 21:15:39 +0000 Subject: [PATCH 5/9] feat: roadmap M3 --- .env.example | 1 + agents.md | 1 + cmd/bot/main.go | 5 +- docker-compose.yml | 1 + docs/architecture.md | 19 +- docs/infra.md | 5 +- docs/roadmap-mvp.md | 16 +- internal/config/config.go | 11 ++ internal/config/config_test.go | 19 +- internal/database/db.go | 20 +- internal/repository/bot_access.go | 123 ++++++++++++ internal/repository/models.go | 16 +- internal/service/bot_access.go | 123 ++++++++++++ internal/service/bot_access_test.go | 235 +++++++++++++++++++++++ internal/telegram/access/handler.go | 89 +++++++++ internal/telegram/access/handler_test.go | 38 ++++ internal/telegram/access/text.go | 5 + internal/telegram/bot.go | 9 +- internal/telegram/menu/handler.go | 17 +- internal/telegram/menu/text.go | 16 +- internal/telegram/menu/text_test.go | 27 +++ internal/telegram/messages.go | 1 + internal/telegram/middleware.go | 21 ++ internal/telegram/routes.go | 1 + 24 files changed, 797 insertions(+), 22 deletions(-) create mode 100644 internal/repository/bot_access.go create mode 100644 internal/service/bot_access.go create mode 100644 internal/service/bot_access_test.go create mode 100644 internal/telegram/access/handler.go create mode 100644 internal/telegram/access/handler_test.go create mode 100644 internal/telegram/access/text.go create mode 100644 internal/telegram/menu/text_test.go diff --git a/.env.example b/.env.example index 1e9ffc4..12ffb86 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ TELEGRAM_BOT_TOKEN= +BOT_OWNER_ID= DATABASE_URL=postgres://pllist:pllist@localhost:5432/pllist?sslmode=disable AUTO_MIGRATE=true POSTGRES_USER=pllist diff --git a/agents.md b/agents.md index 6c99bc6..02f957e 100644 --- a/agents.md +++ b/agents.md @@ -5,6 +5,7 @@ Access-based Telegram bot for creating and managing lists of planned purchases. A user owns lists. A list contains items. Items can be added, marked done, and reserved by the person responsible for buying them. Items carry an urgency level. An owner can share a list with other users as a reader or an editor. +Bot access itself is allowlisted: the owner runs `/allow` and `/kick`. The codebase stays small but extensible. - Architecture: `docs/architecture.md` diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 26ba458..f608691 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -57,6 +57,9 @@ func run() error { } users := service.NewUserService(repository.NewUserRepository(db)) + botAccess := service.NewBotAccessService( + repository.NewBotAccessRepository(db), cfg.BotOwnerID, + ) listRepo := repository.NewListRepository(db) access := service.NewAccessService(listRepo) lists := service.NewListService(listRepo, access) @@ -69,7 +72,7 @@ func run() error { return fmt.Errorf("start long polling: %w", err) } - bot, err := telegram.New(tgBot, updates, users, lists, items, access, pending) + bot, err := telegram.New(tgBot, updates, users, lists, items, access, botAccess, pending) if err != nil { return err } diff --git a/docker-compose.yml b/docker-compose.yml index 16ed81b..b8f363b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: environment: - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - BOT_OWNER_ID=${BOT_OWNER_ID} depends_on: postgres: condition: service_healthy diff --git a/docs/architecture.md b/docs/architecture.md index cad8c2f..2810fdf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,9 +50,10 @@ docs/ architecture, infrastructure, plan, notes ```text telegram/ - bot.go, routes.go, middleware.go lifecycle, routing, identity, per-user ordering + bot.go, routes.go, middleware.go lifecycle, routing, identity, bot-access gate, ordering shared/ sender, user context, nav callbacks, pending text router menu/ /start, /help, /cancel + access/ owner /allow and /kick commands list/ list index, create, rename, delete item/ list menu, paginated item list, item screens, prompts ``` @@ -62,7 +63,12 @@ callbacks live in `shared`. Free-text prompts are dispatched through `shared.PendingRouter`, so the root text handler does not grow with each feature. The `item` package owns the list detail screen because it renders item rows and pagination; the `list` package delegates to it after create and -rename. `share/` gets its own package when M3 lands. +rename. `share/` gets its own package when list sharing (M4) lands. + +The root middleware gate checks bot access before any feature runs. The owner +comes from `BOT_OWNER_ID`; everyone else must have a row in `bot_access`. +Usernames are only stored on access grants so pending grants can be resolved, +and are refreshed when the user contacts the bot. ## Layering @@ -92,7 +98,9 @@ size. ## Domain summary -- `users`: one row per Telegram user, keyed by `telegram_id`. +- `bot_access`: grants bot access by numeric Telegram ID, or by username until + the matching user contacts the bot. +- `users`: one row per allowed Telegram user, keyed by `telegram_id`. - `lists`: owned by exactly one user. - `list_shares`: grants a user `reader` or `editor` access to a list. - `items`: belong to a list, have `status` (`open`/`done`), `urgency` @@ -100,8 +108,9 @@ size. - `pending_shares`: shares granted by username before that user contacts the bot. -One `Authorize(ctx, userID, listID, action)` call is the source of truth for -access. Handlers call it once per update. +Bot access is checked once per update before a user row is ensured. The owner +is always allowed. One `Authorize(ctx, userID, listID, action)` call remains the +source of truth for list access. Schema, access matrix, callback format, and use-case details live in `docs/temp-notes.md`. diff --git a/docs/infra.md b/docs/infra.md index 232ad8d..47607f1 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -40,6 +40,7 @@ Dokploy injects these into the bot container: | Variable | Required | Purpose | | --- | --- | --- | | `TELEGRAM_BOT_TOKEN` | yes | Bot token from BotFather | +| `BOT_OWNER_ID` | yes | Numeric Telegram user ID of the bot owner | | `DATABASE_URL` | yes | `postgres://user:pass@host:5432/db?sslmode=disable` | | `AUTO_MIGRATE` | no | Run GORM AutoMigrate at startup; default `true` | | `POSTGRES_USER` | compose | Database role for local Compose | @@ -109,8 +110,8 @@ jobs: 3. Add an application connected to the Gitea repository. 4. Select the Dockerfile build type and set the build context to the repository root. -5. Set `TELEGRAM_BOT_TOKEN` and `DATABASE_URL` in the application environment. - Use the PostgreSQL service host from step 2. +5. Set `TELEGRAM_BOT_TOKEN`, `BOT_OWNER_ID`, and `DATABASE_URL` in the + application environment. Use the PostgreSQL service host from step 2. 6. Set replicas to `1`. Long polling does not support multiple replicas. 7. Deploy manually. Dokploy pulls the repository, builds the image, and starts the container. diff --git a/docs/roadmap-mvp.md b/docs/roadmap-mvp.md index ad01dc8..91f9157 100644 --- a/docs/roadmap-mvp.md +++ b/docs/roadmap-mvp.md @@ -11,7 +11,7 @@ ## M1 — Users and lists -- Ensure a user row on every update; store only the Telegram ID. +- Ensure a user row on every allowed update; store only the Telegram ID. - Commands: `/start`, `/help`, `/lists`, `/new`, `/cancel`. - Create, rename, and soft-delete a list. Owner only for rename and delete. - List index with owned and shared lists. @@ -24,14 +24,24 @@ - Reserve and release with a conditional update. - List detail view with pagination and role-appropriate buttons. -## M3 — Sharing +## M3 — Bot access + +- Require `BOT_OWNER_ID`; the bot owner always has access. +- Every other user needs an explicit grant. Deny all updates from users without + one, including commands and callbacks. +- Owner commands: `/allow ` and `/kick `. +- Username grants stay pending until that user contacts the bot, then bind to + their Telegram ID. +- Show the owner commands in `/help` for the bot owner only. + +## M4 — Sharing - Grant `reader` or `editor` access by Telegram username or numeric ID. - Pending shares for usernames that cannot be resolved yet. - Resolve pending shares when the user contacts the bot. - Share management view for the owner: list shares, change role, revoke. -## M4 — Polish +## M5 — Polish - Versioned callback-data codec with strict validation. - Confirmation step for destructive actions. diff --git a/internal/config/config.go b/internal/config/config.go index d049573..ed5cae9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( type Config struct { TelegramBotToken string + BotOwnerID int64 DatabaseURL string AutoMigrate bool } @@ -26,6 +27,13 @@ func Load() (Config, error) { DatabaseURL: os.Getenv("DATABASE_URL"), AutoMigrate: true, } + if value := os.Getenv("BOT_OWNER_ID"); value != "" { + ownerID, err := strconv.ParseInt(value, 10, 64) + if err != nil || ownerID <= 0 { + return Config{}, errors.New("BOT_OWNER_ID must be a positive Telegram user ID") + } + cfg.BotOwnerID = ownerID + } if value := os.Getenv("AUTO_MIGRATE"); value != "" { autoMigrate, err := strconv.ParseBool(value) if err != nil { @@ -46,5 +54,8 @@ func (c Config) validate() error { if c.DatabaseURL == "" { return errors.New("DATABASE_URL is required") } + if c.BotOwnerID <= 0 { + return errors.New("BOT_OWNER_ID is required") + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 05225f8..a623d5a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,18 +13,23 @@ func TestValidate(t *testing.T) { }{ { name: "valid", - cfg: Config{TelegramBotToken: "token", DatabaseURL: "postgres://db"}, + cfg: Config{TelegramBotToken: "token", BotOwnerID: 42, DatabaseURL: "postgres://db"}, }, { name: "missing token", - cfg: Config{DatabaseURL: "postgres://db"}, + cfg: Config{BotOwnerID: 42, DatabaseURL: "postgres://db"}, wantErr: "TELEGRAM_BOT_TOKEN", }, { name: "missing database url", - cfg: Config{TelegramBotToken: "token"}, + cfg: Config{TelegramBotToken: "token", BotOwnerID: 42}, wantErr: "DATABASE_URL", }, + { + name: "missing bot owner", + cfg: Config{TelegramBotToken: "token", DatabaseURL: "postgres://db"}, + wantErr: "BOT_OWNER_ID", + }, } for _, tt := range tests { @@ -46,6 +51,7 @@ func TestValidate(t *testing.T) { func TestLoadAutoMigrate(t *testing.T) { t.Chdir(t.TempDir()) t.Setenv("TELEGRAM_BOT_TOKEN", "token") + t.Setenv("BOT_OWNER_ID", "42") t.Setenv("DATABASE_URL", "postgres://db") t.Run("default true", func(t *testing.T) { @@ -75,4 +81,11 @@ func TestLoadAutoMigrate(t *testing.T) { t.Fatal("Load() error = nil, want error") } }) + + t.Run("invalid bot owner", func(t *testing.T) { + t.Setenv("BOT_OWNER_ID", "not-a-number") + if _, err := Load(); err == nil { + t.Fatal("Load() error = nil, want error") + } + }) } diff --git a/internal/database/db.go b/internal/database/db.go index 8e20db6..2ca4a14 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -3,14 +3,19 @@ package database import ( "context" "fmt" + "log" + "os" "time" "gorm.io/driver/postgres" "gorm.io/gorm" + "gorm.io/gorm/logger" ) func Open(ctx context.Context, databaseURL string) (*gorm.DB, error) { - db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{ + Logger: gormLogger(), + }) if err != nil { return nil, fmt.Errorf("open database: %w", err) } @@ -32,3 +37,16 @@ func Open(ctx context.Context, databaseURL string) (*gorm.DB, error) { return db, nil } + +// gormLogger keeps normal "not found" lookups out of the error log. Missing +// rows are expected during access checks and stale callback handling. +func gormLogger() logger.Interface { + return logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: 200 * time.Millisecond, + LogLevel: logger.Warn, + IgnoreRecordNotFoundError: true, + }, + ) +} diff --git a/internal/repository/bot_access.go b/internal/repository/bot_access.go new file mode 100644 index 0000000..786eeca --- /dev/null +++ b/internal/repository/bot_access.go @@ -0,0 +1,123 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type BotAccessRepository struct { + db *gorm.DB +} + +func NewBotAccessRepository(db *gorm.DB) *BotAccessRepository { + return &BotAccessRepository{db: db} +} + +func (r *BotAccessRepository) AllowTelegramID(ctx context.Context, telegramID int64) (bool, error) { + model := BotAccessModel{TelegramID: &telegramID} + result := r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "telegram_id"}}, + DoNothing: true, + }). + Create(&model) + if result.Error != nil { + return false, fmt.Errorf("allow telegram id: %w", result.Error) + } + return result.RowsAffected > 0, nil +} + +func (r *BotAccessRepository) AllowUsername(ctx context.Context, username string) (bool, error) { + model := BotAccessModel{Username: &username} + result := r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "username"}}, + DoNothing: true, + }). + Create(&model) + if result.Error != nil { + return false, fmt.Errorf("allow username: %w", result.Error) + } + return result.RowsAffected > 0, nil +} + +func (r *BotAccessRepository) IsAllowed(ctx context.Context, telegramID int64) (bool, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&BotAccessModel{}). + Where("telegram_id = ?", telegramID). + Count(&count).Error + if err != nil { + return false, fmt.Errorf("check bot access: %w", err) + } + return count > 0, nil +} + +// Claim binds a pending username grant to the user who just contacted the bot. +// If the user already has an ID grant, a duplicate pending row is removed and +// the stored username is refreshed so /kick can use the current handle. +func (r *BotAccessRepository) Claim(ctx context.Context, telegramID int64, username string) error { + if username == "" { + return nil + } + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var byID BotAccessModel + err := tx.Where("telegram_id = ?", telegramID).First(&byID).Error + if err == nil { + if err := tx.Where("username = ? AND id <> ?", username, byID.ID). + Delete(&BotAccessModel{}).Error; err != nil { + return err + } + if byID.Username != nil && *byID.Username == username { + return nil + } + return tx.Model(&byID).Update("username", username).Error + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var pending BotAccessModel + err = tx.Where("username = ?", username).First(&pending).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + // A username can be reassigned. Do not transfer a grant that already + // belongs to another Telegram ID. + if pending.TelegramID != nil && *pending.TelegramID != telegramID { + return nil + } + return tx.Model(&pending).Update("telegram_id", telegramID).Error + }) + if err != nil { + return fmt.Errorf("claim bot access: %w", err) + } + return nil +} + +func (r *BotAccessRepository) RevokeTelegramID(ctx context.Context, telegramID int64) (bool, error) { + result := r.db.WithContext(ctx). + Where("telegram_id = ?", telegramID). + Delete(&BotAccessModel{}) + if result.Error != nil { + return false, fmt.Errorf("revoke telegram id: %w", result.Error) + } + return result.RowsAffected > 0, nil +} + +func (r *BotAccessRepository) RevokeUsername(ctx context.Context, username string) (bool, error) { + result := r.db.WithContext(ctx). + Where("username = ?", username). + Delete(&BotAccessModel{}) + if result.Error != nil { + return false, fmt.Errorf("revoke username: %w", result.Error) + } + return result.RowsAffected > 0, nil +} diff --git a/internal/repository/models.go b/internal/repository/models.go index be90c37..19e1f0b 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -7,7 +7,9 @@ import ( ) func Migrate(db *gorm.DB) error { - return db.AutoMigrate(&UserModel{}, &ListModel{}, &ListShareModel{}, &ItemModel{}) + return db.AutoMigrate( + &UserModel{}, &ListModel{}, &ListShareModel{}, &ItemModel{}, &BotAccessModel{}, + ) } type UserModel struct { @@ -58,3 +60,15 @@ type ItemModel struct { } func (ItemModel) TableName() string { return "items" } + +// BotAccessModel grants bot access. A username grant is pending until the +// matching user contacts the bot; TelegramID is then filled in. +type BotAccessModel struct { + ID int64 `gorm:"primaryKey"` + TelegramID *int64 `gorm:"uniqueIndex"` + Username *string `gorm:"uniqueIndex"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (BotAccessModel) TableName() string { return "bot_access" } diff --git a/internal/service/bot_access.go b/internal/service/bot_access.go new file mode 100644 index 0000000..3804381 --- /dev/null +++ b/internal/service/bot_access.go @@ -0,0 +1,123 @@ +package service + +import ( + "context" + "regexp" + "strconv" + "strings" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +var usernamePattern = regexp.MustCompile(`^[a-z0-9_]{5,32}$`) + +type BotAccessRepository interface { + AllowTelegramID(ctx context.Context, telegramID int64) (bool, error) + AllowUsername(ctx context.Context, username string) (bool, error) + IsAllowed(ctx context.Context, telegramID int64) (bool, error) + Claim(ctx context.Context, telegramID int64, username string) error + RevokeTelegramID(ctx context.Context, telegramID int64) (bool, error) + RevokeUsername(ctx context.Context, username string) (bool, error) +} + +type BotAccessService struct { + access BotAccessRepository + ownerID int64 +} + +func NewBotAccessService(access BotAccessRepository, ownerID int64) *BotAccessService { + return &BotAccessService{access: access, ownerID: ownerID} +} + +func (s *BotAccessService) IsOwner(telegramID int64) bool { + return s.ownerID > 0 && telegramID == s.ownerID +} + +// EnsureAllowed claims any pending username grant and reports whether the +// Telegram user may use the bot. +func (s *BotAccessService) EnsureAllowed( + ctx context.Context, telegramID int64, username string, +) (bool, error) { + if s.IsOwner(telegramID) { + return true, nil + } + if err := s.access.Claim(ctx, telegramID, NormalizeUsername(username)); err != nil { + return false, err + } + return s.access.IsAllowed(ctx, telegramID) +} + +type GrantTarget struct { + TelegramID int64 + Username string +} + +// Allow grants bot access to a username or numeric Telegram ID. It reports +// whether a new grant was created; an existing or pending grant returns false. +func (s *BotAccessService) Allow( + ctx context.Context, ownerID int64, raw string, +) (bool, GrantTarget, error) { + if !s.IsOwner(ownerID) { + return false, GrantTarget{}, domain.ErrForbidden + } + target, err := parseGrantTarget(raw, "Usage: /allow .") + if err != nil { + return false, GrantTarget{}, err + } + if target.TelegramID == s.ownerID { + return false, target, nil + } + if target.TelegramID > 0 { + created, err := s.access.AllowTelegramID(ctx, target.TelegramID) + return created, target, err + } + created, err := s.access.AllowUsername(ctx, target.Username) + return created, target, err +} + +// Kick revokes bot access and reports whether a grant was removed. +func (s *BotAccessService) Kick( + ctx context.Context, ownerID int64, raw string, +) (bool, GrantTarget, error) { + if !s.IsOwner(ownerID) { + return false, GrantTarget{}, domain.ErrForbidden + } + target, err := parseGrantTarget(raw, "Usage: /kick .") + if err != nil { + return false, GrantTarget{}, err + } + if target.TelegramID == s.ownerID { + return false, target, domain.NewValidationError("You cannot revoke your own access.") + } + if target.TelegramID > 0 { + revoked, err := s.access.RevokeTelegramID(ctx, target.TelegramID) + return revoked, target, err + } + revoked, err := s.access.RevokeUsername(ctx, target.Username) + return revoked, target, err +} + +func parseGrantTarget(raw, usage string) (GrantTarget, error) { + target := strings.TrimPrefix(strings.TrimSpace(raw), "@") + if target == "" { + return GrantTarget{}, domain.NewValidationError(usage) + } + if id, err := strconv.ParseInt(target, 10, 64); err == nil { + if id <= 0 { + return GrantTarget{}, domain.NewValidationError("User ID must be positive.") + } + return GrantTarget{TelegramID: id}, nil + } + + username := NormalizeUsername(target) + if !usernamePattern.MatchString(username) { + return GrantTarget{}, domain.NewValidationError( + "Send a Telegram @username or a numeric user ID.", + ) + } + return GrantTarget{Username: username}, nil +} + +func NormalizeUsername(username string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(username), "@")) +} diff --git a/internal/service/bot_access_test.go b/internal/service/bot_access_test.go new file mode 100644 index 0000000..d693b25 --- /dev/null +++ b/internal/service/bot_access_test.go @@ -0,0 +1,235 @@ +package service + +import ( + "context" + "errors" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type fakeBotAccessRepository struct { + allowedIDs map[int64]bool + claimCalls []claimCall + allowIDCalls []int64 + allowNameCalls []string + revokeIDCalls []int64 + revokeNameCall []string + revokeResult bool +} + +type claimCall struct { + telegramID int64 + username string +} + +func (f *fakeBotAccessRepository) AllowTelegramID(_ context.Context, telegramID int64) (bool, error) { + f.allowIDCalls = append(f.allowIDCalls, telegramID) + return true, nil +} + +func (f *fakeBotAccessRepository) AllowUsername(_ context.Context, username string) (bool, error) { + f.allowNameCalls = append(f.allowNameCalls, username) + return true, nil +} + +func (f *fakeBotAccessRepository) IsAllowed(_ context.Context, telegramID int64) (bool, error) { + return f.allowedIDs[telegramID], nil +} + +func (f *fakeBotAccessRepository) Claim( + _ context.Context, telegramID int64, username string, +) error { + f.claimCalls = append(f.claimCalls, claimCall{telegramID: telegramID, username: username}) + return nil +} + +func (f *fakeBotAccessRepository) RevokeTelegramID( + _ context.Context, telegramID int64, +) (bool, error) { + f.revokeIDCalls = append(f.revokeIDCalls, telegramID) + return f.revokeResult, nil +} + +func (f *fakeBotAccessRepository) RevokeUsername(_ context.Context, username string) (bool, error) { + f.revokeNameCall = append(f.revokeNameCall, username) + return f.revokeResult, nil +} + +func TestBotAccessEnsureAllowed(t *testing.T) { + t.Run("owner bypasses the grant table", func(t *testing.T) { + repo := &fakeBotAccessRepository{} + access := NewBotAccessService(repo, 1) + + allowed, err := access.EnsureAllowed(context.Background(), 1, "") + if err != nil || !allowed { + t.Fatalf("EnsureAllowed() = %v, %v, want true, nil", allowed, err) + } + if len(repo.claimCalls) != 0 { + t.Fatalf("owner made claim calls: %v", repo.claimCalls) + } + }) + + t.Run("claims username before checking", func(t *testing.T) { + repo := &fakeBotAccessRepository{allowedIDs: map[int64]bool{2: true}} + access := NewBotAccessService(repo, 1) + + allowed, err := access.EnsureAllowed(context.Background(), 2, "@Alice_1") + if err != nil || !allowed { + t.Fatalf("EnsureAllowed() = %v, %v, want true, nil", allowed, err) + } + want := claimCall{telegramID: 2, username: "alice_1"} + if len(repo.claimCalls) != 1 || repo.claimCalls[0] != want { + t.Fatalf("claim calls = %v, want %v", repo.claimCalls, want) + } + }) + + t.Run("denied user", func(t *testing.T) { + repo := &fakeBotAccessRepository{} + access := NewBotAccessService(repo, 1) + + allowed, err := access.EnsureAllowed(context.Background(), 2, "") + if err != nil || allowed { + t.Fatalf("EnsureAllowed() = %v, %v, want false, nil", allowed, err) + } + }) +} + +func TestBotAccessAllow(t *testing.T) { + t.Run("rejects non-owner", func(t *testing.T) { + repo := &fakeBotAccessRepository{} + access := NewBotAccessService(repo, 1) + + if _, _, err := access.Allow(context.Background(), 2, "alice_1"); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("Allow() error = %v, want ErrForbidden", err) + } + }) + + t.Run("normalizes username", func(t *testing.T) { + repo := &fakeBotAccessRepository{} + access := NewBotAccessService(repo, 1) + + created, target, err := access.Allow(context.Background(), 1, "@Alice_1") + if err != nil || !created { + t.Fatalf("Allow() = %v, %v, want true, nil", created, err) + } + if target.Username != "alice_1" { + t.Fatalf("target username = %q, want alice_1", target.Username) + } + want := []string{"alice_1"} + if len(repo.allowNameCalls) != 1 || repo.allowNameCalls[0] != want[0] { + t.Fatalf("AllowUsername calls = %v, want %v", repo.allowNameCalls, want) + } + }) + + t.Run("numeric id", func(t *testing.T) { + repo := &fakeBotAccessRepository{} + access := NewBotAccessService(repo, 1) + + if _, target, err := access.Allow(context.Background(), 1, "12345"); err != nil { + t.Fatalf("Allow() error = %v", err) + } else if target.TelegramID != 12345 { + t.Fatalf("target ID = %d, want 12345", target.TelegramID) + } + if len(repo.allowIDCalls) != 1 || repo.allowIDCalls[0] != 12345 { + t.Fatalf("AllowTelegramID calls = %v, want [12345]", repo.allowIDCalls) + } + }) + + t.Run("owner is already allowed", func(t *testing.T) { + repo := &fakeBotAccessRepository{} + access := NewBotAccessService(repo, 1) + + created, _, err := access.Allow(context.Background(), 1, "1") + if err != nil || created { + t.Fatalf("Allow() = %v, %v, want false, nil", created, err) + } + if len(repo.allowIDCalls) != 0 { + t.Fatalf("owner grant was written: %v", repo.allowIDCalls) + } + }) +} + +func TestBotAccessKick(t *testing.T) { + t.Run("rejects non-owner", func(t *testing.T) { + repo := &fakeBotAccessRepository{} + access := NewBotAccessService(repo, 1) + + if _, _, err := access.Kick(context.Background(), 2, "alice_1"); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("Kick() error = %v, want ErrForbidden", err) + } + }) + + t.Run("refuses to kick the owner", func(t *testing.T) { + repo := &fakeBotAccessRepository{} + access := NewBotAccessService(repo, 1) + + _, _, err := access.Kick(context.Background(), 1, "1") + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("Kick() error = %v, want ErrInvalidInput", err) + } + if len(repo.revokeIDCalls) != 0 { + t.Fatalf("owner revoke was attempted: %v", repo.revokeIDCalls) + } + }) + + t.Run("revokes by username", func(t *testing.T) { + repo := &fakeBotAccessRepository{revokeResult: true} + access := NewBotAccessService(repo, 1) + + revoked, _, err := access.Kick(context.Background(), 1, "@Alice_1") + if err != nil || !revoked { + t.Fatalf("Kick() = %v, %v, want true, nil", revoked, err) + } + if len(repo.revokeNameCall) != 1 || repo.revokeNameCall[0] != "alice_1" { + t.Fatalf("RevokeUsername calls = %v, want [alice_1]", repo.revokeNameCall) + } + }) + + t.Run("revokes by id", func(t *testing.T) { + repo := &fakeBotAccessRepository{revokeResult: true} + access := NewBotAccessService(repo, 1) + + if _, _, err := access.Kick(context.Background(), 1, "12345"); err != nil { + t.Fatalf("Kick() error = %v", err) + } + if len(repo.revokeIDCalls) != 1 || repo.revokeIDCalls[0] != 12345 { + t.Fatalf("RevokeTelegramID calls = %v, want [12345]", repo.revokeIDCalls) + } + }) +} + +func TestParseGrantTarget(t *testing.T) { + tests := []struct { + name string + raw string + wantID int64 + wantName string + wantErr bool + }{ + {name: "username with at", raw: "@Alice_1", wantName: "alice_1"}, + {name: "username without at", raw: "alice_1", wantName: "alice_1"}, + {name: "numeric id", raw: "12345", wantID: 12345}, + {name: "empty", raw: "", wantErr: true}, + {name: "zero id", raw: "0", wantErr: true}, + {name: "negative id", raw: "-5", wantErr: true}, + {name: "short username", raw: "abcd", wantErr: true}, + {name: "space in username", raw: "alice one", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, err := parseGrantTarget(tt.raw, "usage") + if (err != nil) != tt.wantErr { + t.Fatalf("parseGrantTarget() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if target.TelegramID != tt.wantID || target.Username != tt.wantName { + t.Fatalf("parseGrantTarget() = %+v, want id %d name %q", + target, tt.wantID, tt.wantName) + } + }) + } +} diff --git a/internal/telegram/access/handler.go b/internal/telegram/access/handler.go new file mode 100644 index 0000000..cb600ad --- /dev/null +++ b/internal/telegram/access/handler.go @@ -0,0 +1,89 @@ +package access + +import ( + "fmt" + "strconv" + "strings" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +type Handler struct { + sender *shared.Sender + access *service.BotAccessService +} + +func New(sender *shared.Sender, access *service.BotAccessService) *Handler { + return &Handler{sender: sender, access: access} +} + +func (h *Handler) Register(router *th.BotHandler) { + router.HandleMessage(h.allow, th.CommandEqual("allow")) + router.HandleMessage(h.kick, th.CommandEqual("kick")) +} + +func (h *Handler) allow(ctx *th.Context, message telego.Message) error { + user := shared.UserFrom(ctx) + if !h.access.IsOwner(user.TelegramID) { + return h.sender.Send(ctx, message.Chat.ID, ownerOnlyText, nil) + } + + created, target, err := h.access.Allow(ctx, user.TelegramID, commandArgument(message.Text)) + if err != nil { + return err + } + label := shared.Escape(targetLabel(target)) + + var text string + switch { + case created && target.Username != "": + text = fmt.Sprintf( + "Access granted to %s. It activates when they contact the bot.", label, + ) + case created: + text = fmt.Sprintf("Access granted to %s.", label) + case h.access.IsOwner(target.TelegramID): + text = "You already have access." + default: + text = fmt.Sprintf("%s already has access or a pending grant.", label) + } + return h.sender.Send(ctx, message.Chat.ID, text, nil) +} + +func (h *Handler) kick(ctx *th.Context, message telego.Message) error { + user := shared.UserFrom(ctx) + if !h.access.IsOwner(user.TelegramID) { + return h.sender.Send(ctx, message.Chat.ID, ownerOnlyText, nil) + } + + revoked, target, err := h.access.Kick(ctx, user.TelegramID, commandArgument(message.Text)) + if err != nil { + return err + } + label := shared.Escape(targetLabel(target)) + if !revoked { + return h.sender.Send(ctx, message.Chat.ID, + fmt.Sprintf("No access grant found for %s.", label), nil) + } + return h.sender.Send(ctx, message.Chat.ID, + fmt.Sprintf("Access revoked for %s.", label), nil) +} + +func commandArgument(text string) string { + fields := strings.Fields(text) + if len(fields) < 2 { + return "" + } + return strings.Join(fields[1:], " ") +} + +func targetLabel(target service.GrantTarget) string { + if target.Username != "" { + return "@" + target.Username + } + return strconv.FormatInt(target.TelegramID, 10) +} diff --git a/internal/telegram/access/handler_test.go b/internal/telegram/access/handler_test.go new file mode 100644 index 0000000..5653152 --- /dev/null +++ b/internal/telegram/access/handler_test.go @@ -0,0 +1,38 @@ +package access + +import ( + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +func TestCommandArgument(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + {name: "no argument", text: "/allow", want: ""}, + {name: "username", text: "/allow @alice_1", want: "@alice_1"}, + {name: "numeric id", text: "/kick 12345", want: "12345"}, + {name: "command with bot suffix", text: "/allow@pllist_bot alice_1", want: "alice_1"}, + {name: "extra words", text: "/allow alice_1 extra", want: "alice_1 extra"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := commandArgument(tt.text); got != tt.want { + t.Fatalf("commandArgument(%q) = %q, want %q", tt.text, got, tt.want) + } + }) + } +} + +func TestTargetLabel(t *testing.T) { + if got := targetLabel(service.GrantTarget{Username: "alice_1"}); got != "@alice_1" { + t.Fatalf("targetLabel(username) = %q, want @alice_1", got) + } + if got := targetLabel(service.GrantTarget{TelegramID: 12345}); got != "12345" { + t.Fatalf("targetLabel(id) = %q, want 12345", got) + } +} diff --git a/internal/telegram/access/text.go b/internal/telegram/access/text.go new file mode 100644 index 0000000..49037c2 --- /dev/null +++ b/internal/telegram/access/text.go @@ -0,0 +1,5 @@ +package access + +const ( + ownerOnlyText = "Only the bot owner can do that." +) diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 1e1319a..167649d 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -9,6 +9,7 @@ import ( th "github.com/mymmrac/telego/telegohandler" "git.entityseven.com/rilaxik/pllist/internal/service" + accesstelegram "git.entityseven.com/rilaxik/pllist/internal/telegram/access" itemtelegram "git.entityseven.com/rilaxik/pllist/internal/telegram/item" "git.entityseven.com/rilaxik/pllist/internal/telegram/list" "git.entityseven.com/rilaxik/pllist/internal/telegram/menu" @@ -24,10 +25,12 @@ type Bot struct { api *telego.Bot handler *th.BotHandler users *service.UserService + botAccess *service.BotAccessService pending *service.PendingStore sender *shared.Sender pendingRouter *shared.PendingRouter menu *menu.Handler + access *accesstelegram.Handler list *list.Handler item *itemtelegram.Handler locker *keyedLocker @@ -40,6 +43,7 @@ func New( lists *service.ListService, items *service.ItemService, access *service.AccessService, + botAccess *service.BotAccessService, pending *service.PendingStore, ) (*Bot, error) { sender := shared.NewSender(api) @@ -47,10 +51,12 @@ func New( bot := &Bot{ api: api, users: users, + botAccess: botAccess, pending: pending, sender: sender, pendingRouter: shared.NewPendingRouter(), - menu: menu.New(sender, pending), + menu: menu.New(sender, pending, botAccess), + access: accesstelegram.New(sender, botAccess), list: list.New(sender, lists, access, pending, itemHandler), item: itemHandler, locker: newKeyedLocker(), @@ -70,6 +76,7 @@ func New( bot.answerCallback, bot.serialize, bot.privateOnly, + bot.requireAccess, bot.withUser, ) bot.register() diff --git a/internal/telegram/menu/handler.go b/internal/telegram/menu/handler.go index 0e75cae..37f6367 100644 --- a/internal/telegram/menu/handler.go +++ b/internal/telegram/menu/handler.go @@ -11,10 +11,15 @@ import ( type Handler struct { sender *shared.Sender pending *service.PendingStore + access *service.BotAccessService } -func New(sender *shared.Sender, pending *service.PendingStore) *Handler { - return &Handler{sender: sender, pending: pending} +func New( + sender *shared.Sender, + pending *service.PendingStore, + access *service.BotAccessService, +) *Handler { + return &Handler{sender: sender, pending: pending, access: access} } func (h *Handler) Register(router *th.BotHandler) { @@ -31,7 +36,7 @@ func (h *Handler) start(ctx *th.Context, message telego.Message) error { } func (h *Handler) help(ctx *th.Context, message telego.Message) error { - return h.sender.Send(ctx, message.Chat.ID, helpText, listsButtonKeyboard()) + return h.sender.Send(ctx, message.Chat.ID, h.helpText(ctx), listsButtonKeyboard()) } func (h *Handler) cancel(ctx *th.Context, message telego.Message) error { @@ -43,5 +48,9 @@ func (h *Handler) cancel(ctx *th.Context, message telego.Message) error { func (h *Handler) helpCallback(ctx *th.Context, query telego.CallbackQuery) error { return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query), - helpText, listsButtonKeyboard()) + h.helpText(ctx), listsButtonKeyboard()) +} + +func (h *Handler) helpText(ctx *th.Context) string { + return helpText(h.access.IsOwner(shared.UserFrom(ctx).TelegramID)) } diff --git a/internal/telegram/menu/text.go b/internal/telegram/menu/text.go index 5d346cb..0fa0153 100644 --- a/internal/telegram/menu/text.go +++ b/internal/telegram/menu/text.go @@ -5,11 +5,25 @@ const ( Keep lists of planned purchases, share them, and reserve items. Use the buttons below or /help.` - helpText = `Commands + + baseHelpText = `Commands /lists - your lists /new - create a list /cancel - cancel pending input /help - this message Open a list to add items, reserve them, mark them done, and manage the list.` + + ownerHelpText = ` + +Bot owner +/allow <username or id> - grant bot access +/kick <username or id> - revoke bot access` ) + +func helpText(isOwner bool) string { + if isOwner { + return baseHelpText + ownerHelpText + } + return baseHelpText +} diff --git a/internal/telegram/menu/text_test.go b/internal/telegram/menu/text_test.go new file mode 100644 index 0000000..457b637 --- /dev/null +++ b/internal/telegram/menu/text_test.go @@ -0,0 +1,27 @@ +package menu + +import ( + "strings" + "testing" +) + +func TestHelpTextShowsOwnerCommandsOnlyToOwner(t *testing.T) { + owner := helpText(true) + for _, want := range []string{"/allow", "/kick", "Bot owner"} { + if !strings.Contains(owner, want) { + t.Fatalf("owner help = %q, missing %q", owner, want) + } + } + + user := helpText(false) + for _, unwanted := range []string{"/allow", "/kick", "Bot owner"} { + if strings.Contains(user, unwanted) { + t.Fatalf("user help = %q, contains %q", user, unwanted) + } + } + for _, want := range []string{"/lists", "/new", "/cancel", "/help"} { + if !strings.Contains(user, want) { + t.Fatalf("user help = %q, missing %q", user, want) + } + } +} diff --git a/internal/telegram/messages.go b/internal/telegram/messages.go index 8a5e4f8..e35a52e 100644 --- a/internal/telegram/messages.go +++ b/internal/telegram/messages.go @@ -1,6 +1,7 @@ package telegram const ( + noAccessText = "No access. Ask the bot owner to allow you." privateChatOnlyText = "I work in private chats only." unknownCommandText = "Unknown command. Use /help." helpHintText = "Use /help to see available commands." diff --git a/internal/telegram/middleware.go b/internal/telegram/middleware.go index 6df1b82..6ddce84 100644 --- a/internal/telegram/middleware.go +++ b/internal/telegram/middleware.go @@ -44,6 +44,27 @@ func (b *Bot) privateOnly(ctx *th.Context, update telego.Update) error { return ctx.Next(update) } +// requireAccess is the bot-level gate: the owner and explicitly allowed users +// pass, everyone else gets a single denial and no handler runs. +func (b *Bot) requireAccess(ctx *th.Context, update telego.Update) error { + from := updateFrom(update) + if from == nil || from.IsBot { + return nil + } + allowed, err := b.botAccess.EnsureAllowed(ctx, from.ID, from.Username) + if err != nil { + return err + } + if allowed { + return ctx.Next(update) + } + chatID, ok := updateChatID(update) + if !ok { + return nil + } + return b.sender.Send(ctx, chatID, noAccessText, nil) +} + func (b *Bot) withUser(ctx *th.Context, update telego.Update) error { from := updateFrom(update) if from == nil || from.IsBot { diff --git a/internal/telegram/routes.go b/internal/telegram/routes.go index 0defeae..e14c503 100644 --- a/internal/telegram/routes.go +++ b/internal/telegram/routes.go @@ -4,6 +4,7 @@ import th "github.com/mymmrac/telego/telegohandler" func (b *Bot) register() { b.menu.Register(b.handler) + b.access.Register(b.handler) b.list.Register(b.handler, b.pendingRouter) b.item.Register(b.handler, b.pendingRouter) -- 2.54.0 From 5bfd69cbb6f01dcf092bae58c4dcf85b62e8ac48 Mon Sep 17 00:00:00 2001 From: Danya H Date: Sat, 7 Nov 2026 21:49:44 +0000 Subject: [PATCH 6/9] ci: local deploy changes --- .env.example | 11 +++++++---- docker-compose.yml | 5 ++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 12ffb86..73e93de 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ -TELEGRAM_BOT_TOKEN= -BOT_OWNER_ID= -DATABASE_URL=postgres://pllist:pllist@localhost:5432/pllist?sslmode=disable -AUTO_MIGRATE=true POSTGRES_USER=pllist POSTGRES_PASSWORD=pllist POSTGRES_DB=pllist +POSTGRES_HOST=0.0.0.0 + +TELEGRAM_BOT_TOKEN= +BOT_OWNER_ID= +AUTO_MIGRATE=true + +BOT_OWNER_ID=DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable diff --git a/docker-compose.yml b/docker-compose.yml index b8f363b..cceff8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: . dockerfile: dockerfile - container_name: pllist-bot + pull_policy: build restart: always environment: - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable @@ -19,7 +19,6 @@ services: postgres: image: postgres:16-alpine - container_name: pllist-postgres restart: always environment: POSTGRES_USER: ${POSTGRES_USER} @@ -40,4 +39,4 @@ volumes: networks: bot-net: - driver: bridge + external: true -- 2.54.0 From 39ced0082dbff67e6ea5494760827746d0f7064a Mon Sep 17 00:00:00 2001 From: Danya H Date: Sun, 8 Nov 2026 03:05:46 +0000 Subject: [PATCH 7/9] feat: roadmap M4 --- cmd/bot/main.go | 11 +- docs/architecture.md | 10 +- docs/roadmap-mvp.md | 9 +- internal/domain/list.go | 3 +- internal/domain/share.go | 13 + internal/repository/bot_access.go | 19 + internal/repository/models.go | 18 +- internal/repository/share.go | 298 +++++++++++++++ internal/repository/user.go | 21 +- internal/service/bot_access.go | 49 ++- internal/service/bot_access_test.go | 63 +++- internal/service/session.go | 4 + internal/service/share.go | 257 +++++++++++++ internal/service/share_test.go | 343 ++++++++++++++++++ internal/telegram/bot.go | 6 + internal/telegram/item/keyboards.go | 2 + internal/telegram/item/keyboards_test.go | 2 +- internal/telegram/middleware.go | 3 + internal/telegram/routes.go | 1 + internal/telegram/share/callback_data.go | 164 +++++++++ internal/telegram/share/callback_data_test.go | 81 +++++ internal/telegram/share/callbacks.go | 88 +++++ internal/telegram/share/handler.go | 40 ++ internal/telegram/share/keyboards.go | 67 ++++ internal/telegram/share/pending.go | 31 ++ internal/telegram/share/text.go | 71 ++++ internal/telegram/share/text_test.go | 51 +++ internal/telegram/share/views.go | 25 ++ internal/telegram/shared/callback.go | 5 + 29 files changed, 1726 insertions(+), 29 deletions(-) create mode 100644 internal/domain/share.go create mode 100644 internal/repository/share.go create mode 100644 internal/service/share.go create mode 100644 internal/service/share_test.go create mode 100644 internal/telegram/share/callback_data.go create mode 100644 internal/telegram/share/callback_data_test.go create mode 100644 internal/telegram/share/callbacks.go create mode 100644 internal/telegram/share/handler.go create mode 100644 internal/telegram/share/keyboards.go create mode 100644 internal/telegram/share/pending.go create mode 100644 internal/telegram/share/text.go create mode 100644 internal/telegram/share/text_test.go create mode 100644 internal/telegram/share/views.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index f608691..1855c1b 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -56,15 +56,18 @@ func run() error { return fmt.Errorf("create bot: %w", err) } - users := service.NewUserService(repository.NewUserRepository(db)) + userRepo := repository.NewUserRepository(db) + shareRepo := repository.NewShareRepository(db) + users := service.NewUserService(userRepo) botAccess := service.NewBotAccessService( - repository.NewBotAccessRepository(db), cfg.BotOwnerID, + repository.NewBotAccessRepository(db), cfg.BotOwnerID, shareRepo, ) listRepo := repository.NewListRepository(db) access := service.NewAccessService(listRepo) lists := service.NewListService(listRepo, access) itemRepo := repository.NewItemRepository(db) items := service.NewItemService(itemRepo, access) + shares := service.NewShareService(shareRepo, userRepo, access, botAccess) pending := service.NewPendingStore(pendingTTL) updates, err := tgBot.UpdatesViaLongPolling(ctx, nil) @@ -72,7 +75,9 @@ func run() error { return fmt.Errorf("start long polling: %w", err) } - bot, err := telegram.New(tgBot, updates, users, lists, items, access, botAccess, pending) + bot, err := telegram.New( + tgBot, updates, users, lists, items, shares, access, botAccess, pending, + ) if err != nil { return err } diff --git a/docs/architecture.md b/docs/architecture.md index 2810fdf..d82cb14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,7 @@ telegram/ access/ owner /allow and /kick commands list/ list index, create, rename, delete item/ list menu, paginated item list, item screens, prompts + share/ owner share management, pending shares ``` Features register their own `telegohandler` routes. Cross-feature navigation @@ -63,7 +64,8 @@ callbacks live in `shared`. Free-text prompts are dispatched through `shared.PendingRouter`, so the root text handler does not grow with each feature. The `item` package owns the list detail screen because it renders item rows and pagination; the `list` package delegates to it after create and -rename. `share/` gets its own package when list sharing (M4) lands. +rename. The `share` package owns share management; the item list menu links to +it for owners. The root middleware gate checks bot access before any feature runs. The owner comes from `BOT_OWNER_ID`; everyone else must have a row in `bot_access`. @@ -109,8 +111,10 @@ size. bot. Bot access is checked once per update before a user row is ensured. The owner -is always allowed. One `Authorize(ctx, userID, listID, action)` call remains the -source of truth for list access. +is always allowed. After the user row is ensured, pending shares that match the +user's Telegram ID or username are activated. One `Authorize(ctx, userID, +listID, action)` call remains the source of truth for list access. Revoking bot +access also removes the user's shares; owned lists are never touched. Schema, access matrix, callback format, and use-case details live in `docs/temp-notes.md`. diff --git a/docs/roadmap-mvp.md b/docs/roadmap-mvp.md index 91f9157..bf17a7b 100644 --- a/docs/roadmap-mvp.md +++ b/docs/roadmap-mvp.md @@ -36,10 +36,13 @@ ## M4 — Sharing -- Grant `reader` or `editor` access by Telegram username or numeric ID. -- Pending shares for usernames that cannot be resolved yet. -- Resolve pending shares when the user contacts the bot. +- Grant `reader` or `editor` access by Telegram username or numeric ID to + users who already have bot access. Unknown targets get a no-access hint. +- Pending shares for allowed usernames that have not contacted the bot yet. +- Resolve pending shares when the target user contacts the bot. - Share management view for the owner: list shares, change role, revoke. +- Kicking a user removes their shares from other owners' lists but never + deletes their own lists. ## M5 — Polish diff --git a/internal/domain/list.go b/internal/domain/list.go index d8b1f01..8d910d2 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -16,6 +16,7 @@ const ( ActionViewList Action = iota ActionEditListMeta ActionDeleteList + ActionManageShares ActionViewItems ActionAddItem ActionEditItem @@ -41,7 +42,7 @@ func Allowed(role Role, action Action) bool { switch action { case ActionViewList: return role == RoleOwner || role == RoleEditor || role == RoleReader - case ActionEditListMeta, ActionDeleteList: + case ActionEditListMeta, ActionDeleteList, ActionManageShares: return role == RoleOwner case ActionViewItems: return role == RoleOwner || role == RoleEditor || role == RoleReader diff --git a/internal/domain/share.go b/internal/domain/share.go new file mode 100644 index 0000000..70e72e9 --- /dev/null +++ b/internal/domain/share.go @@ -0,0 +1,13 @@ +package domain + +// Share grants a user access to a list they do not own. A pending share has no +// local user row yet because its Telegram identity has not contacted the bot. +type Share struct { + ID int64 + ListID int64 + UserID int64 + TelegramID int64 + Username string + Role Role + Pending bool +} diff --git a/internal/repository/bot_access.go b/internal/repository/bot_access.go index 786eeca..3620860 100644 --- a/internal/repository/bot_access.go +++ b/internal/repository/bot_access.go @@ -45,6 +45,25 @@ func (r *BotAccessRepository) AllowUsername(ctx context.Context, username string return result.RowsAffected > 0, nil } +// TelegramIDForUsername returns the bound Telegram ID for a username grant. +// found is true for a pending grant, where telegramID is zero. +func (r *BotAccessRepository) TelegramIDForUsername( + ctx context.Context, username string, +) (int64, bool, error) { + var model BotAccessModel + err := r.db.WithContext(ctx).Where("username = ?", username).First(&model).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("get bot access by username: %w", err) + } + if model.TelegramID == nil { + return 0, true, nil + } + return *model.TelegramID, true, nil +} + func (r *BotAccessRepository) IsAllowed(ctx context.Context, telegramID int64) (bool, error) { var count int64 err := r.db.WithContext(ctx). diff --git a/internal/repository/models.go b/internal/repository/models.go index 19e1f0b..d72751b 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -8,7 +8,8 @@ import ( func Migrate(db *gorm.DB) error { return db.AutoMigrate( - &UserModel{}, &ListModel{}, &ListShareModel{}, &ItemModel{}, &BotAccessModel{}, + &UserModel{}, &ListModel{}, &ListShareModel{}, &PendingShareModel{}, + &ItemModel{}, &BotAccessModel{}, ) } @@ -43,6 +44,21 @@ type ListShareModel struct { func (ListShareModel) TableName() string { return "list_shares" } +// PendingShareModel holds a share whose target has bot access but no local +// user row yet. Exactly one of TelegramID and Username is set. +type PendingShareModel struct { + ID int64 `gorm:"primaryKey"` + ListID int64 `gorm:"not null;uniqueIndex:idx_pending_list_telegram,priority:1;uniqueIndex:idx_pending_list_username,priority:1"` + TelegramID *int64 `gorm:"uniqueIndex:idx_pending_list_telegram,priority:2"` + Username *string `gorm:"size:32;uniqueIndex:idx_pending_list_username,priority:2"` + Role string `gorm:"not null;size:16"` + CreatedByID int64 `gorm:"not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (PendingShareModel) TableName() string { return "pending_shares" } + type ItemModel struct { ID int64 `gorm:"primaryKey"` ListID int64 `gorm:"not null;index:idx_items_list_status_urgency_created,priority:1;index:idx_items_list_created,priority:1"` diff --git a/internal/repository/share.go b/internal/repository/share.go new file mode 100644 index 0000000..606eedf --- /dev/null +++ b/internal/repository/share.go @@ -0,0 +1,298 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type ShareRepository struct { + db *gorm.DB +} + +func NewShareRepository(db *gorm.DB) *ShareRepository { + return &ShareRepository{db: db} +} + +type activeShareRow struct { + ID int64 + UserID int64 + TelegramID int64 + Username *string + Role string +} + +func (r *ShareRepository) ListByList(ctx context.Context, listID int64) ([]domain.Share, error) { + var active []activeShareRow + err := r.db.WithContext(ctx). + Table("list_shares"). + Select("list_shares.id, list_shares.user_id, users.telegram_id, bot_access.username, list_shares.role"). + Joins("JOIN users ON users.id = list_shares.user_id"). + Joins("LEFT JOIN bot_access ON bot_access.telegram_id = users.telegram_id"). + Where("list_shares.list_id = ?", listID). + Order("list_shares.created_at ASC"). + Scan(&active). + Error + if err != nil { + return nil, fmt.Errorf("list shares: %w", err) + } + + var pending []PendingShareModel + err = r.db.WithContext(ctx). + Where("list_id = ?", listID). + Order("created_at ASC"). + Find(&pending). + Error + if err != nil { + return nil, fmt.Errorf("list pending shares: %w", err) + } + + shares := make([]domain.Share, 0, len(active)+len(pending)) + for _, row := range active { + share := domain.Share{ + ID: row.ID, + ListID: listID, + UserID: row.UserID, + TelegramID: row.TelegramID, + Role: domain.Role(row.Role), + } + if row.Username != nil { + share.Username = *row.Username + } + shares = append(shares, share) + } + for _, row := range pending { + share := domain.Share{ + ID: row.ID, + ListID: listID, + Role: domain.Role(row.Role), + Pending: true, + } + if row.TelegramID != nil { + share.TelegramID = *row.TelegramID + } + if row.Username != nil { + share.Username = *row.Username + } + shares = append(shares, share) + } + return shares, nil +} + +func (r *ShareRepository) UpsertUser( + ctx context.Context, listID, userID int64, role domain.Role, +) error { + model := ListShareModel{ListID: listID, UserID: userID, Role: string(role)} + err := r.db.WithContext(ctx). + Clauses(userConflict()). + Create(&model). + Error + if err != nil { + return fmt.Errorf("upsert list share: %w", err) + } + return nil +} + +func (r *ShareRepository) UpsertPendingTelegramID( + ctx context.Context, listID, createdByID, telegramID int64, role domain.Role, +) error { + model := PendingShareModel{ + ListID: listID, + TelegramID: &telegramID, + Role: string(role), + CreatedByID: createdByID, + } + err := r.db.WithContext(ctx). + Clauses(pendingConflict("telegram_id")). + Create(&model). + Error + if err != nil { + return fmt.Errorf("upsert pending share by id: %w", err) + } + return nil +} + +func (r *ShareRepository) UpsertPendingUsername( + ctx context.Context, listID, createdByID int64, username string, role domain.Role, +) error { + model := PendingShareModel{ + ListID: listID, + Username: &username, + Role: string(role), + CreatedByID: createdByID, + } + err := r.db.WithContext(ctx). + Clauses(pendingConflict("username")). + Create(&model). + Error + if err != nil { + return fmt.Errorf("upsert pending share by username: %w", err) + } + return nil +} + +func userConflict() clause.OnConflict { + return clause.OnConflict{ + Columns: []clause.Column{{Name: "list_id"}, {Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"role", "updated_at"}), + } +} + +func pendingConflict(target string) clause.OnConflict { + return clause.OnConflict{ + Columns: []clause.Column{{Name: "list_id"}, {Name: target}}, + DoUpdates: clause.AssignmentColumns([]string{"role", "updated_at"}), + } +} + +func (r *ShareRepository) SetUserRole( + ctx context.Context, listID, shareID int64, role domain.Role, +) error { + return r.setRole(ctx, &ListShareModel{}, shareID, listID, role) +} + +func (r *ShareRepository) SetPendingRole( + ctx context.Context, listID, shareID int64, role domain.Role, +) error { + return r.setRole(ctx, &PendingShareModel{}, shareID, listID, role) +} + +func (r *ShareRepository) setRole( + ctx context.Context, model any, shareID, listID int64, role domain.Role, +) error { + result := r.db.WithContext(ctx). + Model(model). + Where("id = ? AND list_id = ?", shareID, listID). + Update("role", string(role)) + if result.Error != nil { + return fmt.Errorf("update share role: %w", result.Error) + } + if result.RowsAffected == 0 { + return domain.ErrNotFound + } + return nil +} + +func (r *ShareRepository) RevokeUser(ctx context.Context, listID, shareID int64) error { + return r.revoke(ctx, &ListShareModel{}, listID, shareID) +} + +func (r *ShareRepository) RevokePending(ctx context.Context, listID, shareID int64) error { + return r.revoke(ctx, &PendingShareModel{}, listID, shareID) +} + +func (r *ShareRepository) revoke( + ctx context.Context, model any, listID, shareID int64, +) error { + result := r.db.WithContext(ctx). + Where("id = ? AND list_id = ?", shareID, listID). + Delete(model) + if result.Error != nil { + return fmt.Errorf("revoke share: %w", result.Error) + } + if result.RowsAffected == 0 { + return domain.ErrNotFound + } + return nil +} + +// ResolvePending moves shares that match the user's Telegram identity into +// list_shares. It runs after the user row is ensured on every allowed update. +func (r *ShareRepository) ResolvePending( + ctx context.Context, userID, telegramID int64, username string, +) error { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + query := tx.Where("telegram_id = ?", telegramID) + if username != "" { + query = query.Or("username = ?", username) + } + + var pending []PendingShareModel + if err := query.Find(&pending).Error; err != nil { + return err + } + if len(pending) == 0 { + return nil + } + + ids := make([]int64, len(pending)) + for i, row := range pending { + ids[i] = row.ID + share := ListShareModel{ + ListID: row.ListID, + UserID: userID, + Role: row.Role, + } + if err := tx.Clauses(userConflict()).Create(&share).Error; err != nil { + return err + } + } + return tx.Where("id IN ?", ids).Delete(&PendingShareModel{}).Error + }) + if err != nil { + return fmt.Errorf("resolve pending shares: %w", err) + } + return nil +} + +// RemoveSharedAccess removes every non-owner list grant for a kicked user. +// Owned lists are untouched because ownership lives on lists.owner_id only. +func (r *ShareRepository) RemoveSharedAccess( + ctx context.Context, telegramID int64, username string, +) error { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if telegramID > 0 { + var userIDs []int64 + if err := tx.Model(&UserModel{}). + Where("telegram_id = ?", telegramID). + Pluck("id", &userIDs). + Error; err != nil { + return err + } + if len(userIDs) > 0 { + if err := tx.Where("user_id IN ?", userIDs). + Delete(&ListShareModel{}). + Error; err != nil { + return err + } + } + if err := tx.Where("telegram_id = ?", telegramID). + Delete(&PendingShareModel{}). + Error; err != nil { + return err + } + + // A pending share may have been stored by username before the + // grant was bound to this Telegram ID. + var grant BotAccessModel + err := tx.Where("telegram_id = ?", telegramID).First(&grant).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err == nil && grant.Username != nil && *grant.Username != "" { + if err := tx.Where("username = ?", *grant.Username). + Delete(&PendingShareModel{}). + Error; err != nil { + return err + } + } + } + if username != "" { + if err := tx.Where("username = ?", username). + Delete(&PendingShareModel{}). + Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return fmt.Errorf("remove shared list access: %w", err) + } + return nil +} diff --git a/internal/repository/user.go b/internal/repository/user.go index f0d2d9b..06308b4 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "gorm.io/gorm" @@ -26,10 +27,28 @@ func (r *UserRepository) Ensure(ctx context.Context, telegramID int64) (domain.U if err != nil { return domain.User{}, fmt.Errorf("ensure user: %w", err) } + return userToDomain(model), nil +} + +func (r *UserRepository) GetByTelegramID( + ctx context.Context, telegramID int64, +) (domain.User, bool, error) { + var model UserModel + err := r.db.WithContext(ctx).Where("telegram_id = ?", telegramID).First(&model).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return domain.User{}, false, nil + } + if err != nil { + return domain.User{}, false, fmt.Errorf("get user by telegram id: %w", err) + } + return userToDomain(model), true, nil +} + +func userToDomain(model UserModel) domain.User { return domain.User{ ID: model.ID, TelegramID: model.TelegramID, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, - }, nil + } } diff --git a/internal/service/bot_access.go b/internal/service/bot_access.go index 3804381..f4a1b8e 100644 --- a/internal/service/bot_access.go +++ b/internal/service/bot_access.go @@ -15,24 +15,49 @@ type BotAccessRepository interface { AllowTelegramID(ctx context.Context, telegramID int64) (bool, error) AllowUsername(ctx context.Context, username string) (bool, error) IsAllowed(ctx context.Context, telegramID int64) (bool, error) + TelegramIDForUsername(ctx context.Context, username string) (int64, bool, error) Claim(ctx context.Context, telegramID int64, username string) error RevokeTelegramID(ctx context.Context, telegramID int64) (bool, error) RevokeUsername(ctx context.Context, username string) (bool, error) } +// SharedAccessCleaner removes list shares when a user loses bot access. +type SharedAccessCleaner interface { + RemoveSharedAccess(ctx context.Context, telegramID int64, username string) error +} + type BotAccessService struct { access BotAccessRepository + cleaner SharedAccessCleaner ownerID int64 } -func NewBotAccessService(access BotAccessRepository, ownerID int64) *BotAccessService { - return &BotAccessService{access: access, ownerID: ownerID} +func NewBotAccessService( + access BotAccessRepository, ownerID int64, cleaner SharedAccessCleaner, +) *BotAccessService { + return &BotAccessService{access: access, cleaner: cleaner, ownerID: ownerID} } func (s *BotAccessService) IsOwner(telegramID int64) bool { return s.ownerID > 0 && telegramID == s.ownerID } +// IsAllowed reports bot access and includes the configured owner. +func (s *BotAccessService) IsAllowed(ctx context.Context, telegramID int64) (bool, error) { + if s.IsOwner(telegramID) { + return true, nil + } + return s.access.IsAllowed(ctx, telegramID) +} + +// TelegramIDForUsername returns the bound Telegram ID for a grant. found is +// true for a pending username grant, where telegramID is zero. +func (s *BotAccessService) TelegramIDForUsername( + ctx context.Context, username string, +) (int64, bool, error) { + return s.access.TelegramIDForUsername(ctx, NormalizeUsername(username)) +} + // EnsureAllowed claims any pending username grant and reports whether the // Telegram user may use the bot. func (s *BotAccessService) EnsureAllowed( @@ -90,13 +115,33 @@ func (s *BotAccessService) Kick( return false, target, domain.NewValidationError("You cannot revoke your own access.") } if target.TelegramID > 0 { + if err := s.removeSharedAccess(ctx, target.TelegramID, ""); err != nil { + return false, target, err + } revoked, err := s.access.RevokeTelegramID(ctx, target.TelegramID) return revoked, target, err } + + telegramID, _, err := s.access.TelegramIDForUsername(ctx, target.Username) + if err != nil { + return false, target, err + } + if err := s.removeSharedAccess(ctx, telegramID, target.Username); err != nil { + return false, target, err + } revoked, err := s.access.RevokeUsername(ctx, target.Username) return revoked, target, err } +func (s *BotAccessService) removeSharedAccess( + ctx context.Context, telegramID int64, username string, +) error { + if s.cleaner == nil { + return nil + } + return s.cleaner.RemoveSharedAccess(ctx, telegramID, username) +} + func parseGrantTarget(raw, usage string) (GrantTarget, error) { target := strings.TrimPrefix(strings.TrimSpace(raw), "@") if target == "" { diff --git a/internal/service/bot_access_test.go b/internal/service/bot_access_test.go index d693b25..be06e83 100644 --- a/internal/service/bot_access_test.go +++ b/internal/service/bot_access_test.go @@ -10,6 +10,7 @@ import ( type fakeBotAccessRepository struct { allowedIDs map[int64]bool + usernameIDs map[string]int64 claimCalls []claimCall allowIDCalls []int64 allowNameCalls []string @@ -18,6 +19,19 @@ type fakeBotAccessRepository struct { revokeResult bool } +type fakeSharedAccessCleaner struct { + telegramIDs []int64 + usernames []string +} + +func (f *fakeSharedAccessCleaner) RemoveSharedAccess( + _ context.Context, telegramID int64, username string, +) error { + f.telegramIDs = append(f.telegramIDs, telegramID) + f.usernames = append(f.usernames, username) + return nil +} + type claimCall struct { telegramID int64 username string @@ -37,6 +51,13 @@ func (f *fakeBotAccessRepository) IsAllowed(_ context.Context, telegramID int64) return f.allowedIDs[telegramID], nil } +func (f *fakeBotAccessRepository) TelegramIDForUsername( + _ context.Context, username string, +) (int64, bool, error) { + id, ok := f.usernameIDs[username] + return id, ok, nil +} + func (f *fakeBotAccessRepository) Claim( _ context.Context, telegramID int64, username string, ) error { @@ -59,7 +80,7 @@ func (f *fakeBotAccessRepository) RevokeUsername(_ context.Context, username str func TestBotAccessEnsureAllowed(t *testing.T) { t.Run("owner bypasses the grant table", func(t *testing.T) { repo := &fakeBotAccessRepository{} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) allowed, err := access.EnsureAllowed(context.Background(), 1, "") if err != nil || !allowed { @@ -72,7 +93,7 @@ func TestBotAccessEnsureAllowed(t *testing.T) { t.Run("claims username before checking", func(t *testing.T) { repo := &fakeBotAccessRepository{allowedIDs: map[int64]bool{2: true}} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) allowed, err := access.EnsureAllowed(context.Background(), 2, "@Alice_1") if err != nil || !allowed { @@ -86,7 +107,7 @@ func TestBotAccessEnsureAllowed(t *testing.T) { t.Run("denied user", func(t *testing.T) { repo := &fakeBotAccessRepository{} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) allowed, err := access.EnsureAllowed(context.Background(), 2, "") if err != nil || allowed { @@ -98,7 +119,7 @@ func TestBotAccessEnsureAllowed(t *testing.T) { func TestBotAccessAllow(t *testing.T) { t.Run("rejects non-owner", func(t *testing.T) { repo := &fakeBotAccessRepository{} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) if _, _, err := access.Allow(context.Background(), 2, "alice_1"); !errors.Is(err, domain.ErrForbidden) { t.Fatalf("Allow() error = %v, want ErrForbidden", err) @@ -107,7 +128,7 @@ func TestBotAccessAllow(t *testing.T) { t.Run("normalizes username", func(t *testing.T) { repo := &fakeBotAccessRepository{} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) created, target, err := access.Allow(context.Background(), 1, "@Alice_1") if err != nil || !created { @@ -124,7 +145,7 @@ func TestBotAccessAllow(t *testing.T) { t.Run("numeric id", func(t *testing.T) { repo := &fakeBotAccessRepository{} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) if _, target, err := access.Allow(context.Background(), 1, "12345"); err != nil { t.Fatalf("Allow() error = %v", err) @@ -138,7 +159,7 @@ func TestBotAccessAllow(t *testing.T) { t.Run("owner is already allowed", func(t *testing.T) { repo := &fakeBotAccessRepository{} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) created, _, err := access.Allow(context.Background(), 1, "1") if err != nil || created { @@ -153,7 +174,7 @@ func TestBotAccessAllow(t *testing.T) { func TestBotAccessKick(t *testing.T) { t.Run("rejects non-owner", func(t *testing.T) { repo := &fakeBotAccessRepository{} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) if _, _, err := access.Kick(context.Background(), 2, "alice_1"); !errors.Is(err, domain.ErrForbidden) { t.Fatalf("Kick() error = %v, want ErrForbidden", err) @@ -162,7 +183,7 @@ func TestBotAccessKick(t *testing.T) { t.Run("refuses to kick the owner", func(t *testing.T) { repo := &fakeBotAccessRepository{} - access := NewBotAccessService(repo, 1) + access := NewBotAccessService(repo, 1, nil) _, _, err := access.Kick(context.Background(), 1, "1") if !errors.Is(err, domain.ErrInvalidInput) { @@ -173,9 +194,13 @@ func TestBotAccessKick(t *testing.T) { } }) - t.Run("revokes by username", func(t *testing.T) { - repo := &fakeBotAccessRepository{revokeResult: true} - access := NewBotAccessService(repo, 1) + t.Run("revokes by username and removes shared lists", func(t *testing.T) { + repo := &fakeBotAccessRepository{ + revokeResult: true, + usernameIDs: map[string]int64{"alice_1": 12345}, + } + cleaner := &fakeSharedAccessCleaner{} + access := NewBotAccessService(repo, 1, cleaner) revoked, _, err := access.Kick(context.Background(), 1, "@Alice_1") if err != nil || !revoked { @@ -184,11 +209,18 @@ func TestBotAccessKick(t *testing.T) { if len(repo.revokeNameCall) != 1 || repo.revokeNameCall[0] != "alice_1" { t.Fatalf("RevokeUsername calls = %v, want [alice_1]", repo.revokeNameCall) } + if len(cleaner.telegramIDs) != 1 || cleaner.telegramIDs[0] != 12345 { + t.Fatalf("cleaner telegram IDs = %v, want [12345]", cleaner.telegramIDs) + } + if len(cleaner.usernames) != 1 || cleaner.usernames[0] != "alice_1" { + t.Fatalf("cleaner usernames = %v, want [alice_1]", cleaner.usernames) + } }) - t.Run("revokes by id", func(t *testing.T) { + t.Run("revokes by id and removes shared lists", func(t *testing.T) { repo := &fakeBotAccessRepository{revokeResult: true} - access := NewBotAccessService(repo, 1) + cleaner := &fakeSharedAccessCleaner{} + access := NewBotAccessService(repo, 1, cleaner) if _, _, err := access.Kick(context.Background(), 1, "12345"); err != nil { t.Fatalf("Kick() error = %v", err) @@ -196,6 +228,9 @@ func TestBotAccessKick(t *testing.T) { if len(repo.revokeIDCalls) != 1 || repo.revokeIDCalls[0] != 12345 { t.Fatalf("RevokeTelegramID calls = %v, want [12345]", repo.revokeIDCalls) } + if len(cleaner.telegramIDs) != 1 || cleaner.telegramIDs[0] != 12345 { + t.Fatalf("cleaner telegram IDs = %v, want [12345]", cleaner.telegramIDs) + } }) } diff --git a/internal/service/session.go b/internal/service/session.go index a3ecc55..c93efff 100644 --- a/internal/service/session.go +++ b/internal/service/session.go @@ -15,12 +15,16 @@ const ( PendingRenameItem PendingAction = "rename_item" PendingEditItemNote PendingAction = "edit_item_note" PendingEditItemQuantity PendingAction = "edit_item_quantity" + + PendingShareTarget PendingAction = "share_target" + PendingShareRole PendingAction = "share_role" ) type Pending struct { Action PendingAction ListID int64 ItemID int64 + Target string expiresAt time.Time } diff --git a/internal/service/share.go b/internal/service/share.go new file mode 100644 index 0000000..882fb28 --- /dev/null +++ b/internal/service/share.go @@ -0,0 +1,257 @@ +package service + +import ( + "context" + "fmt" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type ShareRepository interface { + ListByList(ctx context.Context, listID int64) ([]domain.Share, error) + UpsertUser(ctx context.Context, listID, userID int64, role domain.Role) error + UpsertPendingTelegramID( + ctx context.Context, listID, createdByID, telegramID int64, role domain.Role, + ) error + UpsertPendingUsername( + ctx context.Context, listID, createdByID int64, username string, role domain.Role, + ) error + SetUserRole(ctx context.Context, listID, shareID int64, role domain.Role) error + SetPendingRole(ctx context.Context, listID, shareID int64, role domain.Role) error + RevokeUser(ctx context.Context, listID, shareID int64) error + RevokePending(ctx context.Context, listID, shareID int64) error + ResolvePending(ctx context.Context, userID, telegramID int64, username string) error + RemoveSharedAccess(ctx context.Context, telegramID int64, username string) error +} + +type UserLookup interface { + GetByTelegramID(ctx context.Context, telegramID int64) (domain.User, bool, error) +} + +type ShareService struct { + shares ShareRepository + users UserLookup + access *AccessService + botAccess *BotAccessService +} + +func NewShareService( + shares ShareRepository, + users UserLookup, + access *AccessService, + botAccess *BotAccessService, +) *ShareService { + return &ShareService{ + shares: shares, + users: users, + access: access, + botAccess: botAccess, + } +} + +type ShareList struct { + Access domain.Access + Shares []domain.Share +} + +func (s *ShareService) List( + ctx context.Context, userID, listID int64, +) (ShareList, error) { + access, err := s.access.Authorize(ctx, userID, listID, domain.ActionManageShares) + if err != nil { + return ShareList{}, err + } + shares, err := s.shares.ListByList(ctx, listID) + if err != nil { + return ShareList{}, err + } + return ShareList{Access: access, Shares: shares}, nil +} + +// Find loads the share list and one share so detail views re-check permissions +// against fresh data. +func (s *ShareService) Find( + ctx context.Context, userID, listID, shareID int64, pending bool, +) (ShareList, domain.Share, error) { + list, err := s.List(ctx, userID, listID) + if err != nil { + return ShareList{}, domain.Share{}, err + } + for _, share := range list.Shares { + if share.ID == shareID && share.Pending == pending { + return list, share, nil + } + } + return ShareList{}, domain.Share{}, domain.ErrNotFound +} + +// Prepare validates a share target and returns the label for the role prompt. +func (s *ShareService) Prepare( + ctx context.Context, owner domain.User, listID int64, raw string, +) (string, error) { + target, err := s.resolveTarget(ctx, owner, listID, raw) + if err != nil { + return "", err + } + return target.label(), nil +} + +func (s *ShareService) Share( + ctx context.Context, owner domain.User, listID int64, raw string, role domain.Role, +) error { + if !validShareRole(role) { + return domain.NewValidationError("Choose reader or editor access.") + } + target, err := s.resolveTarget(ctx, owner, listID, raw) + if err != nil { + return err + } + + switch { + case target.userID > 0: + return s.shares.UpsertUser(ctx, listID, target.userID, role) + case target.telegramID > 0: + return s.shares.UpsertPendingTelegramID( + ctx, listID, owner.ID, target.telegramID, role, + ) + default: + return s.shares.UpsertPendingUsername(ctx, listID, owner.ID, target.username, role) + } +} + +func (s *ShareService) SetRole( + ctx context.Context, ownerID, listID, shareID int64, pending bool, role domain.Role, +) error { + if !validShareRole(role) { + return domain.NewValidationError("Choose reader or editor access.") + } + if _, err := s.access.Authorize(ctx, ownerID, listID, domain.ActionManageShares); err != nil { + return err + } + if pending { + return s.shares.SetPendingRole(ctx, listID, shareID, role) + } + return s.shares.SetUserRole(ctx, listID, shareID, role) +} + +func (s *ShareService) Revoke( + ctx context.Context, ownerID, listID, shareID int64, pending bool, +) error { + if _, err := s.access.Authorize(ctx, ownerID, listID, domain.ActionManageShares); err != nil { + return err + } + if pending { + return s.shares.RevokePending(ctx, listID, shareID) + } + return s.shares.RevokeUser(ctx, listID, shareID) +} + +// ResolvePending activates pending shares when the target contacts the bot. +func (s *ShareService) ResolvePending( + ctx context.Context, userID, telegramID int64, username string, +) error { + return s.shares.ResolvePending(ctx, userID, telegramID, NormalizeUsername(username)) +} + +type resolvedShareTarget struct { + userID int64 + telegramID int64 + username string +} + +func (t resolvedShareTarget) label() string { + if t.username != "" { + return "@" + t.username + } + return fmt.Sprintf("ID %d", t.telegramID) +} + +func (s *ShareService) resolveTarget( + ctx context.Context, owner domain.User, listID int64, raw string, +) (resolvedShareTarget, error) { + access, err := s.access.Authorize(ctx, owner.ID, listID, domain.ActionManageShares) + if err != nil { + return resolvedShareTarget{}, err + } + target, err := parseGrantTarget(raw, "Send a Telegram @username or numeric user ID.") + if err != nil { + return resolvedShareTarget{}, err + } + if target.TelegramID == owner.TelegramID { + return resolvedShareTarget{}, domain.NewValidationError( + "You already own this list.", + ) + } + + if target.TelegramID > 0 { + allowed, err := s.botAccess.IsAllowed(ctx, target.TelegramID) + if err != nil { + return resolvedShareTarget{}, err + } + if !allowed { + return resolvedShareTarget{}, noBotAccessError() + } + user, found, err := s.users.GetByTelegramID(ctx, target.TelegramID) + if err != nil { + return resolvedShareTarget{}, err + } + if found { + if user.ID == access.List.OwnerID { + return resolvedShareTarget{}, domain.NewValidationError( + "You already own this list.", + ) + } + return resolvedShareTarget{ + userID: user.ID, + telegramID: user.TelegramID, + }, nil + } + return resolvedShareTarget{telegramID: target.TelegramID}, nil + } + + telegramID, found, err := s.botAccess.TelegramIDForUsername(ctx, target.Username) + if err != nil { + return resolvedShareTarget{}, err + } + if !found { + return resolvedShareTarget{}, noBotAccessError() + } + if telegramID == owner.TelegramID { + return resolvedShareTarget{}, domain.NewValidationError( + "You already own this list.", + ) + } + if telegramID > 0 { + user, found, err := s.users.GetByTelegramID(ctx, telegramID) + if err != nil { + return resolvedShareTarget{}, err + } + if found { + if user.ID == access.List.OwnerID { + return resolvedShareTarget{}, domain.NewValidationError( + "You already own this list.", + ) + } + return resolvedShareTarget{ + userID: user.ID, + telegramID: user.TelegramID, + username: target.Username, + }, nil + } + return resolvedShareTarget{ + telegramID: telegramID, + username: target.Username, + }, nil + } + return resolvedShareTarget{username: target.Username}, nil +} + +func noBotAccessError() error { + return domain.NewValidationError( + "That user does not have bot access. Use /allow first.", + ) +} + +func validShareRole(role domain.Role) bool { + return role == domain.RoleReader || role == domain.RoleEditor +} diff --git a/internal/service/share_test.go b/internal/service/share_test.go new file mode 100644 index 0000000..ecde7b3 --- /dev/null +++ b/internal/service/share_test.go @@ -0,0 +1,343 @@ +package service + +import ( + "context" + "errors" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +type fakeUserLookup struct { + users map[int64]domain.User +} + +func (f *fakeUserLookup) GetByTelegramID( + _ context.Context, telegramID int64, +) (domain.User, bool, error) { + user, ok := f.users[telegramID] + return user, ok, nil +} + +type fakeShareRepository struct { + shares []domain.Share + + userUpserts []shareUserUpsert + pendingIDUpserts []sharePendingIDUpsert + pendingUsernameUpserts []sharePendingUsernameUpsert + setUserRoles []shareRoleUpdate + setPendingRoles []shareRoleUpdate + revokedUserShares []shareRef + revokedPendingShares []shareRef + resolveCalls []resolveCall +} + +type shareUserUpsert struct { + listID int64 + userID int64 + role domain.Role +} + +type sharePendingIDUpsert struct { + listID int64 + createdBy int64 + telegramID int64 + role domain.Role +} + +type sharePendingUsernameUpsert struct { + listID int64 + createdBy int64 + username string + role domain.Role +} + +type shareRoleUpdate struct { + listID int64 + shareID int64 + role domain.Role +} + +type shareRef struct { + listID int64 + shareID int64 +} + +type resolveCall struct { + userID int64 + telegramID int64 + username string +} + +func (f *fakeShareRepository) ListByList( + _ context.Context, _ int64, +) ([]domain.Share, error) { + return f.shares, nil +} + +func (f *fakeShareRepository) UpsertUser( + _ context.Context, listID, userID int64, role domain.Role, +) error { + f.userUpserts = append(f.userUpserts, shareUserUpsert{listID, userID, role}) + return nil +} + +func (f *fakeShareRepository) UpsertPendingTelegramID( + _ context.Context, listID, createdByID, telegramID int64, role domain.Role, +) error { + f.pendingIDUpserts = append(f.pendingIDUpserts, + sharePendingIDUpsert{listID, createdByID, telegramID, role}) + return nil +} + +func (f *fakeShareRepository) UpsertPendingUsername( + _ context.Context, listID, createdByID int64, username string, role domain.Role, +) error { + f.pendingUsernameUpserts = append(f.pendingUsernameUpserts, + sharePendingUsernameUpsert{listID, createdByID, username, role}) + return nil +} + +func (f *fakeShareRepository) SetUserRole( + _ context.Context, listID, shareID int64, role domain.Role, +) error { + f.setUserRoles = append(f.setUserRoles, shareRoleUpdate{listID, shareID, role}) + return nil +} + +func (f *fakeShareRepository) SetPendingRole( + _ context.Context, listID, shareID int64, role domain.Role, +) error { + f.setPendingRoles = append(f.setPendingRoles, shareRoleUpdate{listID, shareID, role}) + return nil +} + +func (f *fakeShareRepository) RevokeUser(_ context.Context, listID, shareID int64) error { + f.revokedUserShares = append(f.revokedUserShares, shareRef{listID, shareID}) + return nil +} + +func (f *fakeShareRepository) RevokePending(_ context.Context, listID, shareID int64) error { + f.revokedPendingShares = append(f.revokedPendingShares, shareRef{listID, shareID}) + return nil +} + +func (f *fakeShareRepository) ResolvePending( + _ context.Context, userID, telegramID int64, username string, +) error { + f.resolveCalls = append(f.resolveCalls, resolveCall{userID, telegramID, username}) + return nil +} + +func (f *fakeShareRepository) RemoveSharedAccess( + _ context.Context, _ int64, _ string, +) error { + return nil +} + +func newTestShareService( + shares *fakeShareRepository, + users *fakeUserLookup, + bot *fakeBotAccessRepository, + lists *fakeAccessRepository, +) *ShareService { + return NewShareService( + shares, users, NewAccessService(lists), + NewBotAccessService(bot, 100, nil), + ) +} + +func TestShareServiceShare(t *testing.T) { + owner := domain.User{ID: 1, TelegramID: 100} + lists := &fakeAccessRepository{ + lists: map[int64]domain.List{ + 10: {ID: 10, OwnerID: 1, Title: "Groceries"}, + }, + } + + t.Run("resolved user by numeric id", func(t *testing.T) { + shares := &fakeShareRepository{} + users := &fakeUserLookup{users: map[int64]domain.User{ + 200: {ID: 2, TelegramID: 200}, + }} + bot := &fakeBotAccessRepository{allowedIDs: map[int64]bool{200: true}} + service := newTestShareService(shares, users, bot, lists) + + err := service.Share(context.Background(), owner, 10, "200", domain.RoleEditor) + if err != nil { + t.Fatalf("Share() error = %v", err) + } + want := shareUserUpsert{listID: 10, userID: 2, role: domain.RoleEditor} + if len(shares.userUpserts) != 1 || shares.userUpserts[0] != want { + t.Fatalf("user upserts = %v, want %v", shares.userUpserts, want) + } + }) + + t.Run("pending username", func(t *testing.T) { + shares := &fakeShareRepository{} + bot := &fakeBotAccessRepository{usernameIDs: map[string]int64{"alice_1": 0}} + service := newTestShareService(shares, &fakeUserLookup{}, bot, lists) + + err := service.Share(context.Background(), owner, 10, "@Alice_1", domain.RoleReader) + if err != nil { + t.Fatalf("Share() error = %v", err) + } + want := sharePendingUsernameUpsert{ + listID: 10, createdBy: 1, username: "alice_1", role: domain.RoleReader, + } + if len(shares.pendingUsernameUpserts) != 1 || + shares.pendingUsernameUpserts[0] != want { + t.Fatalf("pending username upserts = %v, want %v", + shares.pendingUsernameUpserts, want) + } + }) + + t.Run("rejects user without bot access", func(t *testing.T) { + service := newTestShareService( + &fakeShareRepository{}, &fakeUserLookup{}, &fakeBotAccessRepository{}, lists, + ) + + err := service.Share(context.Background(), owner, 10, "@alice_1", domain.RoleEditor) + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("Share() error = %v, want ErrInvalidInput", err) + } + }) + + t.Run("rejects sharing with the owner", func(t *testing.T) { + bot := &fakeBotAccessRepository{allowedIDs: map[int64]bool{100: true}} + users := &fakeUserLookup{users: map[int64]domain.User{ + 100: {ID: 1, TelegramID: 100}, + }} + service := newTestShareService(&fakeShareRepository{}, users, bot, lists) + + err := service.Share(context.Background(), owner, 10, "100", domain.RoleEditor) + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("Share() error = %v, want ErrInvalidInput", err) + } + }) + + t.Run("rejects non-owner", func(t *testing.T) { + readerLists := &fakeAccessRepository{ + lists: map[int64]domain.List{ + 10: {ID: 10, OwnerID: 1, Title: "Groceries"}, + }, + roles: map[[2]int64]domain.Role{{10, 2}: domain.RoleReader}, + } + service := newTestShareService( + &fakeShareRepository{}, &fakeUserLookup{}, &fakeBotAccessRepository{}, readerLists, + ) + + err := service.Share(context.Background(), + domain.User{ID: 2, TelegramID: 200}, 10, "300", domain.RoleEditor) + if !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("Share() error = %v, want ErrForbidden", err) + } + }) + + t.Run("rejects invalid role", func(t *testing.T) { + service := newTestShareService( + &fakeShareRepository{}, &fakeUserLookup{}, &fakeBotAccessRepository{}, lists, + ) + + err := service.Share(context.Background(), owner, 10, "200", domain.RoleOwner) + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("Share() error = %v, want ErrInvalidInput", err) + } + }) +} + +func TestShareServicePendingTargets(t *testing.T) { + owner := domain.User{ID: 1, TelegramID: 100} + lists := &fakeAccessRepository{ + lists: map[int64]domain.List{ + 10: {ID: 10, OwnerID: 1, Title: "Groceries"}, + }, + } + + t.Run("pending telegram id", func(t *testing.T) { + shares := &fakeShareRepository{} + bot := &fakeBotAccessRepository{allowedIDs: map[int64]bool{200: true}} + service := newTestShareService(shares, &fakeUserLookup{}, bot, lists) + + err := service.Share(context.Background(), owner, 10, "200", domain.RoleReader) + if err != nil { + t.Fatalf("Share() error = %v", err) + } + want := sharePendingIDUpsert{ + listID: 10, createdBy: 1, telegramID: 200, role: domain.RoleReader, + } + if len(shares.pendingIDUpserts) != 1 || shares.pendingIDUpserts[0] != want { + t.Fatalf("pending id upserts = %v, want %v", shares.pendingIDUpserts, want) + } + }) + + t.Run("pending username resolves to a known id", func(t *testing.T) { + shares := &fakeShareRepository{} + bot := &fakeBotAccessRepository{usernameIDs: map[string]int64{"alice_1": 200}} + service := newTestShareService(shares, &fakeUserLookup{}, bot, lists) + + if _, err := service.Prepare(context.Background(), owner, 10, "alice_1"); err != nil { + t.Fatalf("Prepare() error = %v", err) + } + err := service.Share(context.Background(), owner, 10, "alice_1", domain.RoleEditor) + if err != nil { + t.Fatalf("Share() error = %v", err) + } + want := sharePendingIDUpsert{ + listID: 10, createdBy: 1, telegramID: 200, role: domain.RoleEditor, + } + if len(shares.pendingIDUpserts) != 1 || shares.pendingIDUpserts[0] != want { + t.Fatalf("pending id upserts = %v, want %v", shares.pendingIDUpserts, want) + } + }) +} + +func TestShareServiceSetRoleAndRevoke(t *testing.T) { + lists := &fakeAccessRepository{ + lists: map[int64]domain.List{ + 10: {ID: 10, OwnerID: 1, Title: "Groceries"}, + }, + } + shares := &fakeShareRepository{} + service := newTestShareService(shares, &fakeUserLookup{}, &fakeBotAccessRepository{}, lists) + + if err := service.SetRole(context.Background(), 1, 10, 55, false, domain.RoleReader); err != nil { + t.Fatalf("SetRole() error = %v", err) + } + if len(shares.setUserRoles) != 1 || + shares.setUserRoles[0] != (shareRoleUpdate{10, 55, domain.RoleReader}) { + t.Fatalf("set user roles = %v", shares.setUserRoles) + } + + if err := service.SetRole(context.Background(), 1, 10, 56, true, domain.RoleEditor); err != nil { + t.Fatalf("SetRole() error = %v", err) + } + if len(shares.setPendingRoles) != 1 || + shares.setPendingRoles[0] != (shareRoleUpdate{10, 56, domain.RoleEditor}) { + t.Fatalf("set pending roles = %v", shares.setPendingRoles) + } + + if err := service.Revoke(context.Background(), 1, 10, 57, false); err != nil { + t.Fatalf("Revoke() error = %v", err) + } + if len(shares.revokedUserShares) != 1 || + shares.revokedUserShares[0] != (shareRef{10, 57}) { + t.Fatalf("revoked user shares = %v", shares.revokedUserShares) + } +} + +func TestShareServiceResolvePending(t *testing.T) { + shares := &fakeShareRepository{} + service := newTestShareService( + shares, &fakeUserLookup{}, &fakeBotAccessRepository{}, + &fakeAccessRepository{}, + ) + + if err := service.ResolvePending(context.Background(), 2, 200, "@Alice_1"); err != nil { + t.Fatalf("ResolvePending() error = %v", err) + } + want := resolveCall{userID: 2, telegramID: 200, username: "alice_1"} + if len(shares.resolveCalls) != 1 || shares.resolveCalls[0] != want { + t.Fatalf("resolve calls = %v, want %v", shares.resolveCalls, want) + } +} diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 167649d..165bae9 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -13,6 +13,7 @@ import ( itemtelegram "git.entityseven.com/rilaxik/pllist/internal/telegram/item" "git.entityseven.com/rilaxik/pllist/internal/telegram/list" "git.entityseven.com/rilaxik/pllist/internal/telegram/menu" + sharetelegram "git.entityseven.com/rilaxik/pllist/internal/telegram/share" "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" ) @@ -26,6 +27,7 @@ type Bot struct { handler *th.BotHandler users *service.UserService botAccess *service.BotAccessService + shares *service.ShareService pending *service.PendingStore sender *shared.Sender pendingRouter *shared.PendingRouter @@ -33,6 +35,7 @@ type Bot struct { access *accesstelegram.Handler list *list.Handler item *itemtelegram.Handler + share *sharetelegram.Handler locker *keyedLocker } @@ -42,6 +45,7 @@ func New( users *service.UserService, lists *service.ListService, items *service.ItemService, + shares *service.ShareService, access *service.AccessService, botAccess *service.BotAccessService, pending *service.PendingStore, @@ -52,6 +56,7 @@ func New( api: api, users: users, botAccess: botAccess, + shares: shares, pending: pending, sender: sender, pendingRouter: shared.NewPendingRouter(), @@ -59,6 +64,7 @@ func New( access: accesstelegram.New(sender, botAccess), list: list.New(sender, lists, access, pending, itemHandler), item: itemHandler, + share: sharetelegram.New(sender, shares, pending), locker: newKeyedLocker(), } diff --git a/internal/telegram/item/keyboards.go b/internal/telegram/item/keyboards.go index b4d9abf..c863bf7 100644 --- a/internal/telegram/item/keyboards.go +++ b/internal/telegram/item/keyboards.go @@ -25,6 +25,8 @@ func listMenuKeyboard(itemPage service.ItemPage) *telego.InlineKeyboardMarkup { rows = append(rows, tu.InlineKeyboardRow( shared.Button("Rename list", shared.RenameListData(access.List.ID)), shared.Button("Delete list", shared.DeleteListData(access.List.ID)), + ), tu.InlineKeyboardRow( + shared.Button("Shares", shared.SharesData(access.List.ID)), )) } rows = append(rows, tu.InlineKeyboardRow( diff --git a/internal/telegram/item/keyboards_test.go b/internal/telegram/item/keyboards_test.go index 4ec19b7..b1e2ffb 100644 --- a/internal/telegram/item/keyboards_test.go +++ b/internal/telegram/item/keyboards_test.go @@ -19,7 +19,7 @@ func TestListMenuKeyboard(t *testing.T) { { name: "owner", role: domain.RoleOwner, - want: []string{"Manage items", "Add item", "Rename list", "Delete list", "My lists"}, + want: []string{"Manage items", "Add item", "Rename list", "Delete list", "Shares", "My lists"}, }, { name: "editor", diff --git a/internal/telegram/middleware.go b/internal/telegram/middleware.go index 6ddce84..fd2281d 100644 --- a/internal/telegram/middleware.go +++ b/internal/telegram/middleware.go @@ -74,6 +74,9 @@ func (b *Bot) withUser(ctx *th.Context, update telego.Update) error { if err != nil { return err } + if err := b.shares.ResolvePending(ctx, user.ID, from.ID, from.Username); err != nil { + return err + } return shared.WithUser(ctx, user).Next(update) } diff --git a/internal/telegram/routes.go b/internal/telegram/routes.go index e14c503..850392c 100644 --- a/internal/telegram/routes.go +++ b/internal/telegram/routes.go @@ -7,6 +7,7 @@ func (b *Bot) register() { b.access.Register(b.handler) b.list.Register(b.handler, b.pendingRouter) b.item.Register(b.handler, b.pendingRouter) + b.share.Register(b.handler, b.pendingRouter) // Catch-alls go last so feature routes match first. b.handler.HandleMessage(b.handleUnknownCommand, th.AnyCommand()) diff --git a/internal/telegram/share/callback_data.go b/internal/telegram/share/callback_data.go new file mode 100644 index 0000000..b939b58 --- /dev/null +++ b/internal/telegram/share/callback_data.go @@ -0,0 +1,164 @@ +package share + +import ( + "fmt" + "strconv" + "strings" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +const ( + callbackAdd = "share_add" + callbackRole = "share_role" + callbackView = "share_view" + callbackSet = "share_set" + callbackRevoke = "share_del" + + refActive = "u" + refPending = "p" +) + +type shareRef struct { + listID int64 + shareID int64 + pending bool +} + +func addData(listID int64) string { + return fmt.Sprintf("%s:%d", callbackAdd, listID) +} + +func roleData(listID int64, role domain.Role) string { + return fmt.Sprintf("%s:%d:%s", callbackRole, listID, role) +} + +func viewData(listID, shareID int64, pending bool) string { + return refData(callbackView, listID, shareID, pending) +} + +func setData(listID, shareID int64, pending bool, role domain.Role) string { + return fmt.Sprintf("%s:%s", refData(callbackSet, listID, shareID, pending), role) +} + +func revokeData(listID, shareID int64, pending bool) string { + return refData(callbackRevoke, listID, shareID, pending) +} + +func refData(action string, listID, shareID int64, pending bool) string { + kind := refActive + if pending { + kind = refPending + } + return fmt.Sprintf("%s:%d:%s:%d", action, listID, kind, shareID) +} + +func parseAdd(data string) (int64, error) { + return shared.CallbackID(data, callbackAdd) +} + +func parseRole(data string) (int64, domain.Role, error) { + raw, ok := strings.CutPrefix(data, callbackRole+":") + if !ok { + return 0, "", shared.ErrBadCallback + } + parts := strings.Split(raw, ":") + if len(parts) != 2 { + return 0, "", shared.ErrBadCallback + } + listID, err := parseID(parts[0]) + if err != nil { + return 0, "", err + } + role, ok := parseRoleValue(parts[1]) + if !ok { + return 0, "", shared.ErrBadCallback + } + return listID, role, nil +} + +func parseView(data string) (shareRef, error) { + return parseRef(data, callbackView) +} + +func parseSet(data string) (shareRef, domain.Role, error) { + raw, ok := strings.CutPrefix(data, callbackSet+":") + if !ok { + return shareRef{}, "", shared.ErrBadCallback + } + parts := strings.Split(raw, ":") + if len(parts) != 4 { + return shareRef{}, "", shared.ErrBadCallback + } + ref, err := parseRefParts(parts[:3]) + if err != nil { + return shareRef{}, "", err + } + role, ok := parseRoleValue(parts[3]) + if !ok { + return shareRef{}, "", shared.ErrBadCallback + } + return ref, role, nil +} + +func parseRevoke(data string) (shareRef, error) { + return parseRef(data, callbackRevoke) +} + +func parseRef(data, action string) (shareRef, error) { + raw, ok := strings.CutPrefix(data, action+":") + if !ok { + return shareRef{}, shared.ErrBadCallback + } + return parseRefParts(strings.Split(raw, ":")) +} + +func parseRefParts(parts []string) (shareRef, error) { + if len(parts) != 3 { + return shareRef{}, shared.ErrBadCallback + } + listID, err := parseID(parts[0]) + if err != nil { + return shareRef{}, err + } + shareID, err := parseID(parts[2]) + if err != nil { + return shareRef{}, err + } + pending, ok := parseRefKind(parts[1]) + if !ok { + return shareRef{}, shared.ErrBadCallback + } + return shareRef{listID: listID, shareID: shareID, pending: pending}, nil +} + +func parseID(raw string) (int64, error) { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + return 0, shared.ErrBadCallback + } + return id, nil +} + +func parseRefKind(raw string) (bool, bool) { + switch raw { + case refActive: + return false, true + case refPending: + return true, true + default: + return false, false + } +} + +func parseRoleValue(raw string) (domain.Role, bool) { + switch domain.Role(raw) { + case domain.RoleReader: + return domain.RoleReader, true + case domain.RoleEditor: + return domain.RoleEditor, true + default: + return "", false + } +} diff --git a/internal/telegram/share/callback_data_test.go b/internal/telegram/share/callback_data_test.go new file mode 100644 index 0000000..289d65b --- /dev/null +++ b/internal/telegram/share/callback_data_test.go @@ -0,0 +1,81 @@ +package share + +import ( + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +func TestShareCallbackRoundTrips(t *testing.T) { + listID, role, err := parseRole(roleData(42, domain.RoleEditor)) + if err != nil || listID != 42 || role != domain.RoleEditor { + t.Fatalf("parseRole() = %d, %q, %v", listID, role, err) + } + + for _, pending := range []bool{false, true} { + ref, err := parseView(viewData(42, 7, pending)) + if err != nil { + t.Fatalf("parseView() error = %v", err) + } + if ref != (shareRef{listID: 42, shareID: 7, pending: pending}) { + t.Fatalf("parseView() = %+v, pending %v", ref, pending) + } + + ref, role, err := parseSet(setData(42, 7, pending, domain.RoleReader)) + if err != nil || role != domain.RoleReader { + t.Fatalf("parseSet() = %+v, %q, %v", ref, role, err) + } + if ref != (shareRef{listID: 42, shareID: 7, pending: pending}) { + t.Fatalf("parseSet() ref = %+v, pending %v", ref, pending) + } + + ref, err = parseRevoke(revokeData(42, 7, pending)) + if err != nil { + t.Fatalf("parseRevoke() error = %v", err) + } + if ref != (shareRef{listID: 42, shareID: 7, pending: pending}) { + t.Fatalf("parseRevoke() = %+v, pending %v", ref, pending) + } + } +} + +func TestShareCallbackRejectsBadData(t *testing.T) { + for _, data := range []string{ + "share_role:42", + "share_role:0:editor", + "share_role:42:owner", + "share_view:42:u", + "share_view:42:x:7", + "share_view:42:u:0", + "share_set:42:u:7", + "share_set:42:u:7:owner", + "share_del:42:u", + } { + if _, err := parseRefOrRole(data); err == nil { + t.Fatalf("accepted invalid callback data %q", data) + } + } +} + +func parseRefOrRole(data string) (any, error) { + ref, role, err := parseSet(data) + if err == nil { + return struct { + ref shareRef + role domain.Role + }{ref, role}, nil + } + if ref, err := parseView(data); err == nil { + return ref, nil + } + if ref, err := parseRevoke(data); err == nil { + return ref, nil + } + if listID, role, err := parseRole(data); err == nil { + return struct { + listID int64 + role domain.Role + }{listID, role}, nil + } + return nil, err +} diff --git a/internal/telegram/share/callbacks.go b/internal/telegram/share/callbacks.go new file mode 100644 index 0000000..c78e675 --- /dev/null +++ b/internal/telegram/share/callbacks.go @@ -0,0 +1,88 @@ +package share + +import ( + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func (h *Handler) manageCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := shared.CallbackID(query.Data, shared.ShareManage) + if err != nil { + return err + } + return h.showShares(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query), listID) +} + +func (h *Handler) addCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, err := parseAdd(query.Data) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if _, err := h.shares.List(ctx, user.ID, listID); err != nil { + return err + } + h.pending.Set(user.TelegramID, service.Pending{ + Action: service.PendingShareTarget, + ListID: listID, + }) + return h.sender.Send(ctx, shared.QueryChatID(query), addSharePrompt, nil) +} + +func (h *Handler) roleCallback(ctx *th.Context, query telego.CallbackQuery) error { + listID, role, err := parseRole(query.Data) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + pending, ok := h.pending.Get(user.TelegramID) + if !ok || pending.Action != service.PendingShareRole || pending.ListID != listID { + return domain.NewValidationError(shareExpiredText) + } + if err := h.shares.Share(ctx, user, listID, pending.Target, role); err != nil { + return err + } + h.pending.Clear(user.TelegramID) + return h.showShares(ctx, user.ID, + shared.QueryChatID(query), shared.QueryMessageID(query), listID) +} + +func (h *Handler) viewCallback(ctx *th.Context, query telego.CallbackQuery) error { + ref, err := parseView(query.Data) + if err != nil { + return err + } + return h.showShare(ctx, shared.UserFrom(ctx).ID, + shared.QueryChatID(query), shared.QueryMessageID(query), ref) +} + +func (h *Handler) setRoleCallback(ctx *th.Context, query telego.CallbackQuery) error { + ref, role, err := parseSet(query.Data) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if err := h.shares.SetRole(ctx, user.ID, ref.listID, ref.shareID, ref.pending, role); err != nil { + return err + } + return h.showShares(ctx, user.ID, + shared.QueryChatID(query), shared.QueryMessageID(query), ref.listID) +} + +func (h *Handler) revokeCallback(ctx *th.Context, query telego.CallbackQuery) error { + ref, err := parseRevoke(query.Data) + if err != nil { + return err + } + user := shared.UserFrom(ctx) + if err := h.shares.Revoke(ctx, user.ID, ref.listID, ref.shareID, ref.pending); err != nil { + return err + } + return h.showShares(ctx, user.ID, + shared.QueryChatID(query), shared.QueryMessageID(query), ref.listID) +} diff --git a/internal/telegram/share/handler.go b/internal/telegram/share/handler.go new file mode 100644 index 0000000..c8f2ea9 --- /dev/null +++ b/internal/telegram/share/handler.go @@ -0,0 +1,40 @@ +package share + +import ( + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +type Handler struct { + sender *shared.Sender + shares *service.ShareService + pending *service.PendingStore +} + +func New( + sender *shared.Sender, + shares *service.ShareService, + pending *service.PendingStore, +) *Handler { + return &Handler{sender: sender, shares: shares, pending: pending} +} + +func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) { + callbacks := router.Group(th.AnyCallbackQueryWithMessage()) + callbacks.HandleCallbackQuery(h.manageCallback, + th.CallbackDataPrefix(shared.ShareManage+":")) + callbacks.HandleCallbackQuery(h.addCallback, + th.CallbackDataPrefix(callbackAdd+":")) + callbacks.HandleCallbackQuery(h.roleCallback, + th.CallbackDataPrefix(callbackRole+":")) + callbacks.HandleCallbackQuery(h.viewCallback, + th.CallbackDataPrefix(callbackView+":")) + callbacks.HandleCallbackQuery(h.setRoleCallback, + th.CallbackDataPrefix(callbackSet+":")) + callbacks.HandleCallbackQuery(h.revokeCallback, + th.CallbackDataPrefix(callbackRevoke+":")) + + pendingRouter.Register(service.PendingShareTarget, h.targetText) +} diff --git a/internal/telegram/share/keyboards.go b/internal/telegram/share/keyboards.go new file mode 100644 index 0000000..4750307 --- /dev/null +++ b/internal/telegram/share/keyboards.go @@ -0,0 +1,67 @@ +package share + +import ( + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +func sharesKeyboard(list service.ShareList) *telego.InlineKeyboardMarkup { + rows := make([][]telego.InlineKeyboardButton, 0, len(list.Shares)+2) + for _, share := range list.Shares { + rows = append(rows, tu.InlineKeyboardRow( + shared.Button(shared.Truncate(shareLabel(share), 40), + viewData(list.Access.List.ID, share.ID, share.Pending)), + )) + } + rows = append(rows, + tu.InlineKeyboardRow( + shared.Button("Add share", addData(list.Access.List.ID)), + ), + tu.InlineKeyboardRow( + shared.Button("Back to list", shared.ListData(list.Access.List.ID)), + ), + ) + return tu.InlineKeyboard(rows...) +} + +func roleKeyboard(listID int64) *telego.InlineKeyboardMarkup { + return tu.InlineKeyboard( + tu.InlineKeyboardRow( + shared.Button("Reader", roleData(listID, domain.RoleReader)), + shared.Button("Editor", roleData(listID, domain.RoleEditor)), + ), + tu.InlineKeyboardRow( + shared.Button("Cancel", shared.SharesData(listID)), + ), + ) +} + +func shareDetailKeyboard(list service.ShareList, share domain.Share) *telego.InlineKeyboardMarkup { + listID := list.Access.List.ID + rows := make([][]telego.InlineKeyboardButton, 0, 4) + roles := make([]telego.InlineKeyboardButton, 0, 2) + if share.Role != domain.RoleReader { + roles = append(roles, shared.Button("Make reader", + setData(listID, share.ID, share.Pending, domain.RoleReader))) + } + if share.Role != domain.RoleEditor { + roles = append(roles, shared.Button("Make editor", + setData(listID, share.ID, share.Pending, domain.RoleEditor))) + } + if len(roles) > 0 { + rows = append(rows, tu.InlineKeyboardRow(roles...)) + } + rows = append(rows, + tu.InlineKeyboardRow( + shared.Button("Revoke", revokeData(listID, share.ID, share.Pending)), + ), + tu.InlineKeyboardRow( + shared.Button("Back to shares", shared.SharesData(listID)), + ), + ) + return tu.InlineKeyboard(rows...) +} diff --git a/internal/telegram/share/pending.go b/internal/telegram/share/pending.go new file mode 100644 index 0000000..1dd4861 --- /dev/null +++ b/internal/telegram/share/pending.go @@ -0,0 +1,31 @@ +package share + +import ( + "errors" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +// targetText receives the username or ID after the Add share prompt. +func (h *Handler) targetText( + ctx *th.Context, user domain.User, pending service.Pending, message telego.Message, +) error { + label, err := h.shares.Prepare(ctx, user, pending.ListID, message.Text) + if err != nil { + if errors.Is(err, domain.ErrInvalidInput) { + h.pending.Set(user.TelegramID, pending) + } + return err + } + h.pending.Set(user.TelegramID, service.Pending{ + Action: service.PendingShareRole, + ListID: pending.ListID, + Target: message.Text, + }) + return h.sender.Send(ctx, message.Chat.ID, roleChoiceText(label), + roleKeyboard(pending.ListID)) +} diff --git a/internal/telegram/share/text.go b/internal/telegram/share/text.go new file mode 100644 index 0000000..f0dfdde --- /dev/null +++ b/internal/telegram/share/text.go @@ -0,0 +1,71 @@ +package share + +import ( + "fmt" + "strings" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" + "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" +) + +const ( + addSharePrompt = "Send the Telegram @username or user ID to share with. /cancel to stop." + shareExpiredText = "This share prompt expired. Open the shares menu again." +) + +func sharesText(list service.ShareList) string { + var text strings.Builder + fmt.Fprintf(&text, "Shares: %s\n\n", shared.Escape(list.Access.List.Title)) + if len(list.Shares) == 0 { + text.WriteString("No shares yet.\n\nOnly users with bot access can be added.") + return text.String() + } + + hasPending := false + for _, share := range list.Shares { + fmt.Fprintf(&text, "• %s — %s", shared.Escape(shareLabel(share)), share.Role) + if share.Pending { + text.WriteString(" (pending)") + hasPending = true + } + text.WriteByte('\n') + } + if hasPending { + text.WriteString("\nPending shares activate when the user contacts the bot.") + } + return strings.TrimRight(text.String(), "\n") +} + +func shareDetailText(list service.ShareList, share domain.Share) string { + var text strings.Builder + fmt.Fprintf(&text, "Share · %s\n", shared.Escape(list.Access.List.Title)) + fmt.Fprintf(&text, "User: %s\nRole: %s\nStatus: %s", + shared.Escape(shareLabel(share)), share.Role, shareStatus(share)) + if share.Pending { + text.WriteString("\n\nThis share activates when the user contacts the bot.") + } + return text.String() +} + +func roleChoiceText(label string) string { + return fmt.Sprintf("Choose a role for %s.", shared.Escape(label)) +} + +func shareLabel(share domain.Share) string { + switch { + case share.Username != "": + return "@" + share.Username + case share.TelegramID > 0: + return fmt.Sprintf("ID %d", share.TelegramID) + default: + return "Unknown user" + } +} + +func shareStatus(share domain.Share) string { + if share.Pending { + return "pending" + } + return "active" +} diff --git a/internal/telegram/share/text_test.go b/internal/telegram/share/text_test.go new file mode 100644 index 0000000..5eefd56 --- /dev/null +++ b/internal/telegram/share/text_test.go @@ -0,0 +1,51 @@ +package share + +import ( + "strings" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" + "git.entityseven.com/rilaxik/pllist/internal/service" +) + +func TestSharesTextShowsActiveAndPendingShares(t *testing.T) { + list := service.ShareList{ + Access: domain.Access{List: domain.List{ID: 1, Title: "Groceries"}}, + Shares: []domain.Share{ + {ID: 1, Username: "alice_1", Role: domain.RoleEditor}, + {ID: 2, TelegramID: 12345, Role: domain.RoleReader, Pending: true}, + }, + } + + text := sharesText(list) + for _, want := range []string{ + "Shares: Groceries", + "@alice_1 — editor", + "ID 12345 — reader (pending)", + "Pending shares activate", + } { + if !strings.Contains(text, want) { + t.Fatalf("sharesText() = %q, missing %q", text, want) + } + } +} + +func TestShareDetailText(t *testing.T) { + list := service.ShareList{ + Access: domain.Access{List: domain.List{ID: 1, Title: "Groceries"}}, + } + text := shareDetailText(list, domain.Share{ + ID: 1, Username: "alice_1", Role: domain.RoleReader, Pending: true, + }) + + for _, want := range []string{ + "Share · Groceries", + "User: @alice_1", + "Role: reader", + "Status: pending", + } { + if !strings.Contains(text, want) { + t.Fatalf("shareDetailText() = %q, missing %q", text, want) + } + } +} diff --git a/internal/telegram/share/views.go b/internal/telegram/share/views.go new file mode 100644 index 0000000..78d00f3 --- /dev/null +++ b/internal/telegram/share/views.go @@ -0,0 +1,25 @@ +package share + +import "context" + +func (h *Handler) showShares( + ctx context.Context, userID, chatID int64, messageID int, listID int64, +) error { + list, err := h.shares.List(ctx, userID, listID) + if err != nil { + return err + } + return h.sender.EditOrSend(ctx, chatID, messageID, + sharesText(list), sharesKeyboard(list)) +} + +func (h *Handler) showShare( + ctx context.Context, userID, chatID int64, messageID int, ref shareRef, +) error { + list, share, err := h.shares.Find(ctx, userID, ref.listID, ref.shareID, ref.pending) + if err != nil { + return err + } + return h.sender.EditOrSend(ctx, chatID, messageID, + shareDetailText(list, share), shareDetailKeyboard(list, share)) +} diff --git a/internal/telegram/shared/callback.go b/internal/telegram/shared/callback.go index a8dbf08..592b87e 100644 --- a/internal/telegram/shared/callback.go +++ b/internal/telegram/shared/callback.go @@ -26,6 +26,7 @@ const ( ListRename = "rename_list" ListDelete = "delete_list" ListDeleteYes = "delete_list_yes" + ShareManage = "shares" ) func ListData(id int64) string { @@ -44,6 +45,10 @@ func DeleteListYesData(id int64) string { return CallbackWithID(ListDeleteYes, id) } +func SharesData(id int64) string { + return CallbackWithID(ShareManage, id) +} + func CallbackWithID(action string, id int64) string { return fmt.Sprintf("%s:%d", action, id) } -- 2.54.0 From ac8049a88aec2efff38a27108cefd667238a6ae8 Mon Sep 17 00:00:00 2001 From: Danya H Date: Sun, 8 Nov 2026 03:46:11 +0000 Subject: [PATCH 8/9] feat: roadmap M5 --- cmd/bot/main.go | 19 +- docs/architecture.md | 7 +- internal/telegram/bot.go | 7 + internal/telegram/handlers.go | 13 +- internal/telegram/item/callback_data.go | 62 ++-- internal/telegram/item/callback_data_test.go | 14 +- internal/telegram/item/callbacks.go | 8 +- internal/telegram/item/handler.go | 54 ++-- internal/telegram/item/keyboards.go | 3 - internal/telegram/item/keyboards_test.go | 6 +- internal/telegram/list/callbacks.go | 6 +- internal/telegram/list/handler.go | 10 +- internal/telegram/menu/handler.go | 6 +- internal/telegram/menu/keyboards.go | 24 -- internal/telegram/menu/text.go | 2 +- internal/telegram/messages.go | 1 + internal/telegram/middleware.go | 21 +- internal/telegram/share/callback_data.go | 133 ++------- internal/telegram/share/callback_data_test.go | 43 ++- internal/telegram/share/callbacks.go | 16 +- internal/telegram/share/handler.go | 14 +- internal/telegram/share/keyboards.go | 9 + internal/telegram/share/text.go | 7 + internal/telegram/shared/callback.go | 279 ++++++++++++++++-- internal/telegram/shared/callback_test.go | 167 +++++++++-- internal/telegram/shared/keyboard.go | 22 ++ internal/telegram/shared/keyboard_test.go | 19 ++ internal/telegram/shared/sender.go | 11 +- internal/telegram/shared/sender_test.go | 8 + internal/telegram/shared/text.go | 14 + internal/telegram/shared/text_test.go | 19 ++ internal/telegram/shared/ui.go | 107 +++++++ internal/telegram/shared/ui_test.go | 64 ++++ 33 files changed, 888 insertions(+), 307 deletions(-) delete mode 100644 internal/telegram/menu/keyboards.go create mode 100644 internal/telegram/shared/keyboard.go create mode 100644 internal/telegram/shared/keyboard_test.go create mode 100644 internal/telegram/shared/ui_test.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 1855c1b..ed3fa75 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -3,7 +3,7 @@ package main import ( "context" "fmt" - "log" + "log/slog" "os" "os/signal" "syscall" @@ -21,12 +21,17 @@ import ( const pendingTTL = 5 * time.Minute func main() { - if err := run(); err != nil { - log.Fatal(err) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + if err := run(logger); err != nil { + logger.Error("bot stopped with error", "error", err) + os.Exit(1) } } -func run() error { +func run(logger *slog.Logger) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("load config: %w", err) @@ -76,17 +81,17 @@ func run() error { } bot, err := telegram.New( - tgBot, updates, users, lists, items, shares, access, botAccess, pending, + tgBot, updates, users, lists, items, shares, access, botAccess, pending, logger, ) if err != nil { return err } - log.Println("bot started") + logger.Info("bot started") if err := bot.Run(); err != nil { return err } - log.Println("bot stopped") + logger.Info("bot stopped") return nil } diff --git a/docs/architecture.md b/docs/architecture.md index d82cb14..c7d14e4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ docs/ architecture, infrastructure, plan, notes ```text telegram/ bot.go, routes.go, middleware.go lifecycle, routing, identity, bot-access gate, ordering - shared/ sender, user context, nav callbacks, pending text router + shared/ sender, reply keyboard, callback codec, user context, pending text router menu/ /start, /help, /cancel access/ owner /allow and /kick commands list/ list index, create, rename, delete @@ -59,8 +59,9 @@ telegram/ share/ owner share management, pending shares ``` -Features register their own `telegohandler` routes. Cross-feature navigation -callbacks live in `shared`. Free-text prompts are dispatched through +Features register their own `telegohandler` routes. All callback data is built +and validated in `shared`; feature packages only wrap the typed builders. +Free-text prompts are dispatched through `shared.PendingRouter`, so the root text handler does not grow with each feature. The `item` package owns the list detail screen because it renders item rows and pagination; the `list` package delegates to it after create and diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 165bae9..d5ab23c 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -3,6 +3,7 @@ package telegram import ( "context" "fmt" + "log/slog" "time" "github.com/mymmrac/telego" @@ -25,6 +26,7 @@ const ( type Bot struct { api *telego.Bot handler *th.BotHandler + logger *slog.Logger users *service.UserService botAccess *service.BotAccessService shares *service.ShareService @@ -49,11 +51,16 @@ func New( access *service.AccessService, botAccess *service.BotAccessService, pending *service.PendingStore, + logger *slog.Logger, ) (*Bot, error) { + if logger == nil { + logger = slog.Default() + } sender := shared.NewSender(api) itemHandler := itemtelegram.New(sender, items, access, pending) bot := &Bot{ api: api, + logger: logger, users: users, botAccess: botAccess, shares: shares, diff --git a/internal/telegram/handlers.go b/internal/telegram/handlers.go index 40822fc..de8de8a 100644 --- a/internal/telegram/handlers.go +++ b/internal/telegram/handlers.go @@ -35,9 +35,12 @@ func (b *Bot) handleOtherMessage(ctx *th.Context, message telego.Message) error return b.sender.Send(ctx, message.Chat.ID, textOnlyText, nil) } -// handleUnknownCallback catches buttons from an older bot version, including -// callbacks whose message is no longer accessible. The callback is answered by -// middleware; leaving the message untouched is enough. -func (b *Bot) handleUnknownCallback(_ *th.Context, _ telego.CallbackQuery) error { - return nil +// handleUnknownCallback catches callbacks that no feature understands, such as +// buttons from an older bot version. The callback is answered by middleware; +// send a short hint when the message is still available. +func (b *Bot) handleUnknownCallback(ctx *th.Context, query telego.CallbackQuery) error { + if query.Message == nil { + return nil + } + return b.sender.Send(ctx, shared.QueryChatID(query), staleCallbackText, nil) } diff --git a/internal/telegram/item/callback_data.go b/internal/telegram/item/callback_data.go index 4a33306..2a4f7b8 100644 --- a/internal/telegram/item/callback_data.go +++ b/internal/telegram/item/callback_data.go @@ -1,32 +1,26 @@ package item -import ( - "fmt" - "strconv" - "strings" - - "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" -) +import "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" const ( - callbackPage = "page" - callbackAdd = "add" - callbackItem = "item" + callbackPage = shared.ActionItemPage + callbackAdd = shared.ActionItemAdd + callbackItem = shared.ActionItemOpen - callbackManage = "manage" - callbackEditTitle = "edit_title" - callbackEditNote = "edit_note" - callbackEditQuantity = "edit_qty" - callbackClearNote = "clear_note" - callbackUrgencyLow = "urgency_low" - callbackUrgencyNorm = "urgency_normal" - callbackUrgencyHigh = "urgency_high" - callbackDone = "done_item" - callbackReopen = "reopen_item" - callbackReserve = "reserve_item" - callbackRelease = "release_item" - callbackDelete = "delete_item" - callbackDeleteYes = "delete_item_yes" + callbackManage = shared.ActionItemManage + callbackEditTitle = shared.ActionItemEditTitle + callbackEditNote = shared.ActionItemEditNote + callbackEditQuantity = shared.ActionItemEditQuantity + callbackClearNote = shared.ActionItemClearNote + callbackUrgencyLow = shared.ActionItemUrgencyLow + callbackUrgencyNorm = shared.ActionItemUrgencyNorm + callbackUrgencyHigh = shared.ActionItemUrgencyHigh + callbackDone = shared.ActionItemDone + callbackReopen = shared.ActionItemReopen + callbackReserve = shared.ActionItemReserve + callbackRelease = shared.ActionItemRelease + callbackDelete = shared.ActionItemDelete + callbackDeleteYes = shared.ActionItemDeleteYes ) func addData(listID int64) string { @@ -42,25 +36,9 @@ func manageData(listID int64) string { } func pageData(listID int64, page int) string { - return fmt.Sprintf("%s:%d:%d", callbackPage, listID, page) + return shared.PageData(listID, page) } func parsePage(data string) (int64, int, error) { - raw, ok := strings.CutPrefix(data, callbackPage+":") - if !ok { - return 0, 0, shared.ErrBadCallback - } - parts := strings.Split(raw, ":") - if len(parts) != 2 { - return 0, 0, shared.ErrBadCallback - } - listID, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil || listID <= 0 { - return 0, 0, shared.ErrBadCallback - } - page, err := strconv.Atoi(parts[1]) - if err != nil || page < 1 { - return 0, 0, shared.ErrBadCallback - } - return listID, page, nil + return shared.ParsePage(data) } diff --git a/internal/telegram/item/callback_data_test.go b/internal/telegram/item/callback_data_test.go index aa10891..8b0145a 100644 --- a/internal/telegram/item/callback_data_test.go +++ b/internal/telegram/item/callback_data_test.go @@ -15,13 +15,13 @@ func TestPageDataRoundTrip(t *testing.T) { func TestParsePageRejectsBadData(t *testing.T) { for _, data := range []string{ - "page", - "page:42", - "page:0:1", - "page:42:0", - "page:42:abc", - "page:42:1:extra", - "other:42:1", + "v1:page", + "v1:page:42", + "v1:page:0:1", + "v1:page:42:0", + "v1:page:42:abc", + "v1:page:42:1:extra", + "page:42:1", } { if _, _, err := parsePage(data); err == nil { t.Fatalf("parsePage(%q) accepted invalid data", data) diff --git a/internal/telegram/item/callbacks.go b/internal/telegram/item/callbacks.go index 822ad5b..101bf99 100644 --- a/internal/telegram/item/callbacks.go +++ b/internal/telegram/item/callbacks.go @@ -13,7 +13,7 @@ import ( ) func (h *Handler) listCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, shared.ListOpen) + listID, err := shared.CallbackID(query.Data, shared.ActionListOpen) if err != nil { return err } @@ -79,7 +79,7 @@ func (h *Handler) editQuantityCallback(ctx *th.Context, query telego.CallbackQue func (h *Handler) promptItemEdit( ctx *th.Context, query telego.CallbackQuery, - callbackAction string, + callbackAction shared.CallbackAction, action service.PendingAction, prompt string, ) error { @@ -124,7 +124,7 @@ func (h *Handler) highUrgencyCallback(ctx *th.Context, query telego.CallbackQuer } func (h *Handler) setUrgency( - ctx *th.Context, query telego.CallbackQuery, action string, urgency domain.Urgency, + ctx *th.Context, query telego.CallbackQuery, action shared.CallbackAction, urgency domain.Urgency, ) error { itemID, err := shared.CallbackID(query.Data, action) if err != nil { @@ -157,7 +157,7 @@ func (h *Handler) releaseCallback(ctx *th.Context, query telego.CallbackQuery) e func (h *Handler) mutateItem( ctx *th.Context, query telego.CallbackQuery, - action string, + action shared.CallbackAction, mutate func(context.Context, int64, int64) error, ) error { itemID, err := shared.CallbackID(query.Data, action) diff --git a/internal/telegram/item/handler.go b/internal/telegram/item/handler.go index a6cbb1d..54ffdfc 100644 --- a/internal/telegram/item/handler.go +++ b/internal/telegram/item/handler.go @@ -30,24 +30,42 @@ func New( func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) { callbacks := router.Group(th.AnyCallbackQueryWithMessage()) - callbacks.HandleCallbackQuery(h.listCallback, th.CallbackDataPrefix(shared.ListOpen+":")) - callbacks.HandleCallbackQuery(h.pageCallback, th.CallbackDataPrefix(callbackPage+":")) - callbacks.HandleCallbackQuery(h.addCallback, th.CallbackDataPrefix(callbackAdd+":")) - callbacks.HandleCallbackQuery(h.itemCallback, th.CallbackDataPrefix(callbackItem+":")) - callbacks.HandleCallbackQuery(h.manageCallback, th.CallbackDataPrefix(callbackManage+":")) - callbacks.HandleCallbackQuery(h.editTitleCallback, th.CallbackDataPrefix(callbackEditTitle+":")) - callbacks.HandleCallbackQuery(h.editNoteCallback, th.CallbackDataPrefix(callbackEditNote+":")) - callbacks.HandleCallbackQuery(h.editQuantityCallback, th.CallbackDataPrefix(callbackEditQuantity+":")) - callbacks.HandleCallbackQuery(h.clearNoteCallback, th.CallbackDataPrefix(callbackClearNote+":")) - callbacks.HandleCallbackQuery(h.lowUrgencyCallback, th.CallbackDataPrefix(callbackUrgencyLow+":")) - callbacks.HandleCallbackQuery(h.normalUrgencyCallback, th.CallbackDataPrefix(callbackUrgencyNorm+":")) - callbacks.HandleCallbackQuery(h.highUrgencyCallback, th.CallbackDataPrefix(callbackUrgencyHigh+":")) - callbacks.HandleCallbackQuery(h.doneCallback, th.CallbackDataPrefix(callbackDone+":")) - callbacks.HandleCallbackQuery(h.reopenCallback, th.CallbackDataPrefix(callbackReopen+":")) - callbacks.HandleCallbackQuery(h.reserveCallback, th.CallbackDataPrefix(callbackReserve+":")) - callbacks.HandleCallbackQuery(h.releaseCallback, th.CallbackDataPrefix(callbackRelease+":")) - callbacks.HandleCallbackQuery(h.deleteCallback, th.CallbackDataPrefix(callbackDelete+":")) - callbacks.HandleCallbackQuery(h.deleteYesCallback, th.CallbackDataPrefix(callbackDeleteYes+":")) + callbacks.HandleCallbackQuery(h.listCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionListOpen))) + callbacks.HandleCallbackQuery(h.pageCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackPage))) + callbacks.HandleCallbackQuery(h.addCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackAdd))) + callbacks.HandleCallbackQuery(h.itemCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackItem))) + callbacks.HandleCallbackQuery(h.manageCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackManage))) + callbacks.HandleCallbackQuery(h.editTitleCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackEditTitle))) + callbacks.HandleCallbackQuery(h.editNoteCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackEditNote))) + callbacks.HandleCallbackQuery(h.editQuantityCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackEditQuantity))) + callbacks.HandleCallbackQuery(h.clearNoteCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackClearNote))) + callbacks.HandleCallbackQuery(h.lowUrgencyCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackUrgencyLow))) + callbacks.HandleCallbackQuery(h.normalUrgencyCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackUrgencyNorm))) + callbacks.HandleCallbackQuery(h.highUrgencyCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackUrgencyHigh))) + callbacks.HandleCallbackQuery(h.doneCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackDone))) + callbacks.HandleCallbackQuery(h.reopenCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackReopen))) + callbacks.HandleCallbackQuery(h.reserveCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackReserve))) + callbacks.HandleCallbackQuery(h.releaseCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackRelease))) + callbacks.HandleCallbackQuery(h.deleteCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackDelete))) + callbacks.HandleCallbackQuery(h.deleteYesCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackDeleteYes))) pendingRouter.Register(service.PendingCreateItem, h.createText) pendingRouter.Register(service.PendingRenameItem, h.renameText) diff --git a/internal/telegram/item/keyboards.go b/internal/telegram/item/keyboards.go index c863bf7..5396a35 100644 --- a/internal/telegram/item/keyboards.go +++ b/internal/telegram/item/keyboards.go @@ -29,9 +29,6 @@ func listMenuKeyboard(itemPage service.ItemPage) *telego.InlineKeyboardMarkup { shared.Button("Shares", shared.SharesData(access.List.ID)), )) } - rows = append(rows, tu.InlineKeyboardRow( - shared.Button("My lists", shared.NavLists), - )) return tu.InlineKeyboard(rows...) } diff --git a/internal/telegram/item/keyboards_test.go b/internal/telegram/item/keyboards_test.go index b1e2ffb..e7da456 100644 --- a/internal/telegram/item/keyboards_test.go +++ b/internal/telegram/item/keyboards_test.go @@ -19,17 +19,17 @@ func TestListMenuKeyboard(t *testing.T) { { name: "owner", role: domain.RoleOwner, - want: []string{"Manage items", "Add item", "Rename list", "Delete list", "Shares", "My lists"}, + want: []string{"Manage items", "Add item", "Rename list", "Delete list", "Shares"}, }, { name: "editor", role: domain.RoleEditor, - want: []string{"Manage items", "Add item", "My lists"}, + want: []string{"Manage items", "Add item"}, }, { name: "reader", role: domain.RoleReader, - want: []string{"Manage items", "My lists"}, + want: []string{"Manage items"}, }, } diff --git a/internal/telegram/list/callbacks.go b/internal/telegram/list/callbacks.go index 37dd331..fa2256b 100644 --- a/internal/telegram/list/callbacks.go +++ b/internal/telegram/list/callbacks.go @@ -19,7 +19,7 @@ func (h *Handler) newCallback(ctx *th.Context, query telego.CallbackQuery) error } func (h *Handler) renameCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, shared.ListRename) + listID, err := shared.CallbackID(query.Data, shared.ActionListRename) if err != nil { return err } @@ -34,7 +34,7 @@ func (h *Handler) renameCallback(ctx *th.Context, query telego.CallbackQuery) er } func (h *Handler) deleteCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, shared.ListDelete) + listID, err := shared.CallbackID(query.Data, shared.ActionListDelete) if err != nil { return err } @@ -46,7 +46,7 @@ func (h *Handler) deleteCallback(ctx *th.Context, query telego.CallbackQuery) er } func (h *Handler) deleteYesCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, shared.ListDeleteYes) + listID, err := shared.CallbackID(query.Data, shared.ActionListDeleteYes) if err != nil { return err } diff --git a/internal/telegram/list/handler.go b/internal/telegram/list/handler.go index 15ea669..48f1606 100644 --- a/internal/telegram/list/handler.go +++ b/internal/telegram/list/handler.go @@ -34,14 +34,18 @@ func New( func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) { router.HandleMessage(h.handleLists, th.CommandEqual("lists")) + router.HandleMessage(h.handleLists, th.TextEqual(shared.MyListsLabel)) router.HandleMessage(h.handleNew, th.CommandEqual("new")) callbacks := router.Group(th.AnyCallbackQueryWithMessage()) callbacks.HandleCallbackQuery(h.listsCallback, th.CallbackDataEqual(shared.NavLists)) callbacks.HandleCallbackQuery(h.newCallback, th.CallbackDataEqual(shared.NavNew)) - callbacks.HandleCallbackQuery(h.renameCallback, th.CallbackDataPrefix(shared.ListRename+":")) - callbacks.HandleCallbackQuery(h.deleteCallback, th.CallbackDataPrefix(shared.ListDelete+":")) - callbacks.HandleCallbackQuery(h.deleteYesCallback, th.CallbackDataPrefix(shared.ListDeleteYes+":")) + callbacks.HandleCallbackQuery(h.renameCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionListRename))) + callbacks.HandleCallbackQuery(h.deleteCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionListDelete))) + callbacks.HandleCallbackQuery(h.deleteYesCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionListDeleteYes))) pendingRouter.Register(service.PendingCreateList, h.createText) pendingRouter.Register(service.PendingRenameList, h.renameText) diff --git a/internal/telegram/menu/handler.go b/internal/telegram/menu/handler.go index 37f6367..6944115 100644 --- a/internal/telegram/menu/handler.go +++ b/internal/telegram/menu/handler.go @@ -32,11 +32,11 @@ func (h *Handler) Register(router *th.BotHandler) { } func (h *Handler) start(ctx *th.Context, message telego.Message) error { - return h.sender.Send(ctx, message.Chat.ID, startText, startKeyboard()) + return h.sender.Send(ctx, message.Chat.ID, startText, shared.MainKeyboard()) } func (h *Handler) help(ctx *th.Context, message telego.Message) error { - return h.sender.Send(ctx, message.Chat.ID, h.helpText(ctx), listsButtonKeyboard()) + return h.sender.Send(ctx, message.Chat.ID, h.helpText(ctx), shared.MainKeyboard()) } func (h *Handler) cancel(ctx *th.Context, message telego.Message) error { @@ -48,7 +48,7 @@ func (h *Handler) cancel(ctx *th.Context, message telego.Message) error { func (h *Handler) helpCallback(ctx *th.Context, query telego.CallbackQuery) error { return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query), - h.helpText(ctx), listsButtonKeyboard()) + h.helpText(ctx), nil) } func (h *Handler) helpText(ctx *th.Context) string { diff --git a/internal/telegram/menu/keyboards.go b/internal/telegram/menu/keyboards.go deleted file mode 100644 index 2c59373..0000000 --- a/internal/telegram/menu/keyboards.go +++ /dev/null @@ -1,24 +0,0 @@ -package menu - -import ( - "github.com/mymmrac/telego" - tu "github.com/mymmrac/telego/telegoutil" - - "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" -) - -func startKeyboard() *telego.InlineKeyboardMarkup { - return tu.InlineKeyboard( - tu.InlineKeyboardRow( - shared.Button("My lists", shared.NavLists), - shared.Button("New list", shared.NavNew), - ), - tu.InlineKeyboardRow(shared.Button("Help", shared.NavHelp)), - ) -} - -func listsButtonKeyboard() *telego.InlineKeyboardMarkup { - return tu.InlineKeyboard( - tu.InlineKeyboardRow(shared.Button("My lists", shared.NavLists)), - ) -} diff --git a/internal/telegram/menu/text.go b/internal/telegram/menu/text.go index 0fa0153..d668dbb 100644 --- a/internal/telegram/menu/text.go +++ b/internal/telegram/menu/text.go @@ -4,7 +4,7 @@ const ( startText = `pllist Keep lists of planned purchases, share them, and reserve items. -Use the buttons below or /help.` +Use the button below or /help.` baseHelpText = `Commands /lists - your lists diff --git a/internal/telegram/messages.go b/internal/telegram/messages.go index e35a52e..f202ce0 100644 --- a/internal/telegram/messages.go +++ b/internal/telegram/messages.go @@ -7,4 +7,5 @@ const ( helpHintText = "Use /help to see available commands." textOnlyText = "Send text only." promptExpiredText = "That prompt is no longer active. Use /help." + staleCallbackText = "That button is out of date. Use /lists." ) diff --git a/internal/telegram/middleware.go b/internal/telegram/middleware.go index fd2281d..495fe87 100644 --- a/internal/telegram/middleware.go +++ b/internal/telegram/middleware.go @@ -1,8 +1,6 @@ package telegram import ( - "log" - "github.com/mymmrac/telego" th "github.com/mymmrac/telego/telegohandler" @@ -15,7 +13,8 @@ func (b *Bot) answerCallback(ctx *th.Context, update telego.Update) error { CallbackQueryID: update.CallbackQuery.ID, }) if err != nil { - log.Printf("answer callback %s: %v", update.CallbackQuery.ID, err) + b.logger.Warn("answer callback failed", + "callback_id", update.CallbackQuery.ID, "error", err) } } return ctx.Next(update) @@ -81,12 +80,24 @@ func (b *Bot) withUser(ctx *th.Context, update telego.Update) error { } func (b *Bot) handleError(ctx *th.Context, update telego.Update, err error) { - log.Printf("update %d: %v", update.UpdateID, err) + attrs := []any{"update_id", update.UpdateID} + if from := updateFrom(update); from != nil { + attrs = append(attrs, "user_id", from.ID) + } chatID, ok := updateChatID(update) + if ok { + attrs = append(attrs, "chat_id", chatID) + } + attrs = append(attrs, "error", err) + if shared.IsExpectedError(err) { + b.logger.Warn("update rejected", attrs...) + } else { + b.logger.Error("update failed", attrs...) + } if !ok { return } if sendErr := b.sender.Send(ctx, chatID, shared.UserMessage(err), nil); sendErr != nil { - log.Printf("send error message: %v", sendErr) + b.logger.Error("send error message failed", "chat_id", chatID, "error", sendErr) } } diff --git a/internal/telegram/share/callback_data.go b/internal/telegram/share/callback_data.go index b939b58..c681985 100644 --- a/internal/telegram/share/callback_data.go +++ b/internal/telegram/share/callback_data.go @@ -1,23 +1,17 @@ package share import ( - "fmt" - "strconv" - "strings" - "git.entityseven.com/rilaxik/pllist/internal/domain" "git.entityseven.com/rilaxik/pllist/internal/telegram/shared" ) const ( - callbackAdd = "share_add" - callbackRole = "share_role" - callbackView = "share_view" - callbackSet = "share_set" - callbackRevoke = "share_del" - - refActive = "u" - refPending = "p" + callbackAdd = shared.ActionShareAdd + callbackRole = shared.ActionShareRole + callbackView = shared.ActionShareView + callbackSet = shared.ActionShareSet + callbackRevoke = shared.ActionShareRevoke + callbackRevokeYes = shared.ActionShareRevokeYes ) type shareRef struct { @@ -27,31 +21,27 @@ type shareRef struct { } func addData(listID int64) string { - return fmt.Sprintf("%s:%d", callbackAdd, listID) + return shared.ShareAddData(listID) } func roleData(listID int64, role domain.Role) string { - return fmt.Sprintf("%s:%d:%s", callbackRole, listID, role) + return shared.ShareRoleData(listID, role) } func viewData(listID, shareID int64, pending bool) string { - return refData(callbackView, listID, shareID, pending) + return shared.ShareViewData(shareRefData(listID, shareID, pending)) } func setData(listID, shareID int64, pending bool, role domain.Role) string { - return fmt.Sprintf("%s:%s", refData(callbackSet, listID, shareID, pending), role) + return shared.ShareSetData(shareRefData(listID, shareID, pending), role) } func revokeData(listID, shareID int64, pending bool) string { - return refData(callbackRevoke, listID, shareID, pending) + return shared.ShareRevokeData(shareRefData(listID, shareID, pending)) } -func refData(action string, listID, shareID int64, pending bool) string { - kind := refActive - if pending { - kind = refPending - } - return fmt.Sprintf("%s:%d:%s:%d", action, listID, kind, shareID) +func revokeYesData(listID, shareID int64, pending bool) string { + return shared.ShareRevokeYesData(shareRefData(listID, shareID, pending)) } func parseAdd(data string) (int64, error) { @@ -59,23 +49,7 @@ func parseAdd(data string) (int64, error) { } func parseRole(data string) (int64, domain.Role, error) { - raw, ok := strings.CutPrefix(data, callbackRole+":") - if !ok { - return 0, "", shared.ErrBadCallback - } - parts := strings.Split(raw, ":") - if len(parts) != 2 { - return 0, "", shared.ErrBadCallback - } - listID, err := parseID(parts[0]) - if err != nil { - return 0, "", err - } - role, ok := parseRoleValue(parts[1]) - if !ok { - return 0, "", shared.ErrBadCallback - } - return listID, role, nil + return shared.ParseShareRole(data) } func parseView(data string) (shareRef, error) { @@ -83,82 +57,37 @@ func parseView(data string) (shareRef, error) { } func parseSet(data string) (shareRef, domain.Role, error) { - raw, ok := strings.CutPrefix(data, callbackSet+":") - if !ok { - return shareRef{}, "", shared.ErrBadCallback - } - parts := strings.Split(raw, ":") - if len(parts) != 4 { - return shareRef{}, "", shared.ErrBadCallback - } - ref, err := parseRefParts(parts[:3]) + ref, role, err := shared.ParseShareSet(data) if err != nil { return shareRef{}, "", err } - role, ok := parseRoleValue(parts[3]) - if !ok { - return shareRef{}, "", shared.ErrBadCallback - } - return ref, role, nil + return shareRef{ + listID: ref.ListID, + shareID: ref.ShareID, + pending: ref.Pending, + }, role, nil } func parseRevoke(data string) (shareRef, error) { return parseRef(data, callbackRevoke) } -func parseRef(data, action string) (shareRef, error) { - raw, ok := strings.CutPrefix(data, action+":") - if !ok { - return shareRef{}, shared.ErrBadCallback - } - return parseRefParts(strings.Split(raw, ":")) +func parseRevokeYes(data string) (shareRef, error) { + return parseRef(data, callbackRevokeYes) } -func parseRefParts(parts []string) (shareRef, error) { - if len(parts) != 3 { - return shareRef{}, shared.ErrBadCallback - } - listID, err := parseID(parts[0]) +func parseRef(data string, action shared.CallbackAction) (shareRef, error) { + ref, err := shared.ParseShareRef(data, action) if err != nil { return shareRef{}, err } - shareID, err := parseID(parts[2]) - if err != nil { - return shareRef{}, err - } - pending, ok := parseRefKind(parts[1]) - if !ok { - return shareRef{}, shared.ErrBadCallback - } - return shareRef{listID: listID, shareID: shareID, pending: pending}, nil + return shareRef{ + listID: ref.ListID, + shareID: ref.ShareID, + pending: ref.Pending, + }, nil } -func parseID(raw string) (int64, error) { - id, err := strconv.ParseInt(raw, 10, 64) - if err != nil || id <= 0 { - return 0, shared.ErrBadCallback - } - return id, nil -} - -func parseRefKind(raw string) (bool, bool) { - switch raw { - case refActive: - return false, true - case refPending: - return true, true - default: - return false, false - } -} - -func parseRoleValue(raw string) (domain.Role, bool) { - switch domain.Role(raw) { - case domain.RoleReader: - return domain.RoleReader, true - case domain.RoleEditor: - return domain.RoleEditor, true - default: - return "", false - } +func shareRefData(listID, shareID int64, pending bool) shared.ShareRef { + return shared.ShareRef{ListID: listID, ShareID: shareID, Pending: pending} } diff --git a/internal/telegram/share/callback_data_test.go b/internal/telegram/share/callback_data_test.go index 289d65b..e3e5afb 100644 --- a/internal/telegram/share/callback_data_test.go +++ b/internal/telegram/share/callback_data_test.go @@ -13,43 +13,53 @@ func TestShareCallbackRoundTrips(t *testing.T) { } for _, pending := range []bool{false, true} { + want := shareRef{listID: 42, shareID: 7, pending: pending} + ref, err := parseView(viewData(42, 7, pending)) if err != nil { t.Fatalf("parseView() error = %v", err) } - if ref != (shareRef{listID: 42, shareID: 7, pending: pending}) { - t.Fatalf("parseView() = %+v, pending %v", ref, pending) + if ref != want { + t.Fatalf("parseView() = %+v, want %+v", ref, want) } ref, role, err := parseSet(setData(42, 7, pending, domain.RoleReader)) if err != nil || role != domain.RoleReader { t.Fatalf("parseSet() = %+v, %q, %v", ref, role, err) } - if ref != (shareRef{listID: 42, shareID: 7, pending: pending}) { - t.Fatalf("parseSet() ref = %+v, pending %v", ref, pending) + if ref != want { + t.Fatalf("parseSet() ref = %+v, want %+v", ref, want) } ref, err = parseRevoke(revokeData(42, 7, pending)) if err != nil { t.Fatalf("parseRevoke() error = %v", err) } - if ref != (shareRef{listID: 42, shareID: 7, pending: pending}) { - t.Fatalf("parseRevoke() = %+v, pending %v", ref, pending) + if ref != want { + t.Fatalf("parseRevoke() = %+v, want %+v", ref, want) + } + + ref, err = parseRevokeYes(revokeYesData(42, 7, pending)) + if err != nil { + t.Fatalf("parseRevokeYes() error = %v", err) + } + if ref != want { + t.Fatalf("parseRevokeYes() = %+v, want %+v", ref, want) } } } func TestShareCallbackRejectsBadData(t *testing.T) { for _, data := range []string{ - "share_role:42", - "share_role:0:editor", - "share_role:42:owner", - "share_view:42:u", - "share_view:42:x:7", - "share_view:42:u:0", - "share_set:42:u:7", - "share_set:42:u:7:owner", - "share_del:42:u", + "v1:share_role:42", + "v1:share_role:0:editor", + "v1:share_role:42:owner", + "v1:share_view:42:u", + "v1:share_view:42:x:7", + "v1:share_view:42:u:0", + "v1:share_set:42:u:7", + "v1:share_set:42:u:7:owner", + "v1:share_revoke:42:u", } { if _, err := parseRefOrRole(data); err == nil { t.Fatalf("accepted invalid callback data %q", data) @@ -71,6 +81,9 @@ func parseRefOrRole(data string) (any, error) { if ref, err := parseRevoke(data); err == nil { return ref, nil } + if ref, err := parseRevokeYes(data); err == nil { + return ref, nil + } if listID, role, err := parseRole(data); err == nil { return struct { listID int64 diff --git a/internal/telegram/share/callbacks.go b/internal/telegram/share/callbacks.go index c78e675..b207813 100644 --- a/internal/telegram/share/callbacks.go +++ b/internal/telegram/share/callbacks.go @@ -10,7 +10,7 @@ import ( ) func (h *Handler) manageCallback(ctx *th.Context, query telego.CallbackQuery) error { - listID, err := shared.CallbackID(query.Data, shared.ShareManage) + listID, err := shared.CallbackID(query.Data, shared.ActionShareManage) if err != nil { return err } @@ -80,6 +80,20 @@ func (h *Handler) revokeCallback(ctx *th.Context, query telego.CallbackQuery) er return err } user := shared.UserFrom(ctx) + list, share, err := h.shares.Find(ctx, user.ID, ref.listID, ref.shareID, ref.pending) + if err != nil { + return err + } + return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query), + revokeConfirmText(list, share), revokeConfirmKeyboard(ref)) +} + +func (h *Handler) revokeYesCallback(ctx *th.Context, query telego.CallbackQuery) error { + ref, err := parseRevokeYes(query.Data) + if err != nil { + return err + } + user := shared.UserFrom(ctx) if err := h.shares.Revoke(ctx, user.ID, ref.listID, ref.shareID, ref.pending); err != nil { return err } diff --git a/internal/telegram/share/handler.go b/internal/telegram/share/handler.go index c8f2ea9..75f3f27 100644 --- a/internal/telegram/share/handler.go +++ b/internal/telegram/share/handler.go @@ -24,17 +24,19 @@ func New( func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) { callbacks := router.Group(th.AnyCallbackQueryWithMessage()) callbacks.HandleCallbackQuery(h.manageCallback, - th.CallbackDataPrefix(shared.ShareManage+":")) + th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionShareManage))) callbacks.HandleCallbackQuery(h.addCallback, - th.CallbackDataPrefix(callbackAdd+":")) + th.CallbackDataPrefix(shared.CallbackPrefix(callbackAdd))) callbacks.HandleCallbackQuery(h.roleCallback, - th.CallbackDataPrefix(callbackRole+":")) + th.CallbackDataPrefix(shared.CallbackPrefix(callbackRole))) callbacks.HandleCallbackQuery(h.viewCallback, - th.CallbackDataPrefix(callbackView+":")) + th.CallbackDataPrefix(shared.CallbackPrefix(callbackView))) callbacks.HandleCallbackQuery(h.setRoleCallback, - th.CallbackDataPrefix(callbackSet+":")) + th.CallbackDataPrefix(shared.CallbackPrefix(callbackSet))) callbacks.HandleCallbackQuery(h.revokeCallback, - th.CallbackDataPrefix(callbackRevoke+":")) + th.CallbackDataPrefix(shared.CallbackPrefix(callbackRevoke))) + callbacks.HandleCallbackQuery(h.revokeYesCallback, + th.CallbackDataPrefix(shared.CallbackPrefix(callbackRevokeYes))) pendingRouter.Register(service.PendingShareTarget, h.targetText) } diff --git a/internal/telegram/share/keyboards.go b/internal/telegram/share/keyboards.go index 4750307..0f444e9 100644 --- a/internal/telegram/share/keyboards.go +++ b/internal/telegram/share/keyboards.go @@ -65,3 +65,12 @@ func shareDetailKeyboard(list service.ShareList, share domain.Share) *telego.Inl ) return tu.InlineKeyboard(rows...) } + +func revokeConfirmKeyboard(ref shareRef) *telego.InlineKeyboardMarkup { + return tu.InlineKeyboard( + tu.InlineKeyboardRow( + shared.Button("Yes, revoke", revokeYesData(ref.listID, ref.shareID, ref.pending)), + shared.Button("Cancel", viewData(ref.listID, ref.shareID, ref.pending)), + ), + ) +} diff --git a/internal/telegram/share/text.go b/internal/telegram/share/text.go index f0dfdde..04847d0 100644 --- a/internal/telegram/share/text.go +++ b/internal/telegram/share/text.go @@ -48,6 +48,13 @@ func shareDetailText(list service.ShareList, share domain.Share) string { return text.String() } +func revokeConfirmText(list service.ShareList, share domain.Share) string { + return fmt.Sprintf( + "Revoke access?\n\n%s will lose access to \"%s\".", + shared.Escape(shareLabel(share)), shared.Escape(list.Access.List.Title), + ) +} + func roleChoiceText(label string) string { return fmt.Sprintf("Choose a role for %s.", shared.Escape(label)) } diff --git a/internal/telegram/shared/callback.go b/internal/telegram/shared/callback.go index 592b87e..1a2a88f 100644 --- a/internal/telegram/shared/callback.go +++ b/internal/telegram/shared/callback.go @@ -2,69 +2,298 @@ package shared import ( "errors" - "fmt" "strconv" "strings" "github.com/mymmrac/telego" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) + +const ( + callbackVersion = "v1" + MaxCallbackDataLength = 64 ) var ErrBadCallback = errors.New("invalid callback data") -// Navigation callbacks are shared because several features render the same -// menu buttons. +// CallbackAction is a stable, versioned callback identifier. Actions are short +// because Telegram limits callback data to 64 bytes. +type CallbackAction string + const ( - NavLists = "lists" - NavNew = "new" - NavHelp = "help" + ActionNavLists CallbackAction = "lists" + ActionNavNew CallbackAction = "new" + ActionNavHelp CallbackAction = "help" + + ActionListOpen CallbackAction = "open_list" + ActionListRename CallbackAction = "rename_list" + ActionListDelete CallbackAction = "delete_list" + ActionListDeleteYes CallbackAction = "delete_list_yes" + ActionShareManage CallbackAction = "shares" + + ActionItemPage CallbackAction = "page" + ActionItemAdd CallbackAction = "add" + ActionItemOpen CallbackAction = "item" + ActionItemManage CallbackAction = "manage" + ActionItemEditTitle CallbackAction = "edit_title" + ActionItemEditNote CallbackAction = "edit_note" + ActionItemEditQuantity CallbackAction = "edit_qty" + ActionItemClearNote CallbackAction = "clear_note" + ActionItemUrgencyLow CallbackAction = "urgency_low" + ActionItemUrgencyNorm CallbackAction = "urgency_normal" + ActionItemUrgencyHigh CallbackAction = "urgency_high" + ActionItemDone CallbackAction = "done_item" + ActionItemReopen CallbackAction = "reopen_item" + ActionItemReserve CallbackAction = "reserve_item" + ActionItemRelease CallbackAction = "release_item" + ActionItemDelete CallbackAction = "delete_item" + ActionItemDeleteYes CallbackAction = "delete_item_yes" + + ActionShareAdd CallbackAction = "share_add" + ActionShareRole CallbackAction = "share_role" + ActionShareView CallbackAction = "share_view" + ActionShareSet CallbackAction = "share_set" + ActionShareRevoke CallbackAction = "share_revoke" + ActionShareRevokeYes CallbackAction = "share_revoke_yes" ) -// List callbacks are shared because the item package renders list detail -// screens while the list package handles list metadata actions. +// Navigation actions carry no arguments and are compared verbatim. const ( - ListOpen = "list" - ListRename = "rename_list" - ListDelete = "delete_list" - ListDeleteYes = "delete_list_yes" - ShareManage = "shares" + NavLists = callbackVersion + ":" + string(ActionNavLists) + NavNew = callbackVersion + ":" + string(ActionNavNew) + NavHelp = callbackVersion + ":" + string(ActionNavHelp) ) func ListData(id int64) string { - return CallbackWithID(ListOpen, id) + return CallbackWithID(ActionListOpen, id) } func RenameListData(id int64) string { - return CallbackWithID(ListRename, id) + return CallbackWithID(ActionListRename, id) } func DeleteListData(id int64) string { - return CallbackWithID(ListDelete, id) + return CallbackWithID(ActionListDelete, id) } func DeleteListYesData(id int64) string { - return CallbackWithID(ListDeleteYes, id) + return CallbackWithID(ActionListDeleteYes, id) } func SharesData(id int64) string { - return CallbackWithID(ShareManage, id) + return CallbackWithID(ActionShareManage, id) } -func CallbackWithID(action string, id int64) string { - return fmt.Sprintf("%s:%d", action, id) +func CallbackPrefix(action CallbackAction) string { + return callbackVersion + ":" + string(action) + ":" } -func CallbackID(data, action string) (int64, error) { - rawID, ok := strings.CutPrefix(data, action+":") - if !ok { - return 0, ErrBadCallback +func CallbackWithID(action CallbackAction, id int64) string { + return encodeCallback(action, strconv.FormatInt(id, 10)) +} + +func CallbackID(data string, action CallbackAction) (int64, error) { + args, err := callbackArgs(data, action, 1) + if err != nil { + return 0, err } - id, err := strconv.ParseInt(rawID, 10, 64) + return parsePositiveID(args[0]) +} + +// PageData and ParsePage cover the item pagination callback. +func PageData(listID int64, page int) string { + return encodeCallback(ActionItemPage, + strconv.FormatInt(listID, 10), strconv.Itoa(page)) +} + +func ParsePage(data string) (int64, int, error) { + args, err := callbackArgs(data, ActionItemPage, 2) + if err != nil { + return 0, 0, err + } + listID, err := parsePositiveID(args[0]) + if err != nil { + return 0, 0, err + } + page, err := strconv.Atoi(args[1]) + if err != nil || page < 1 { + return 0, 0, ErrBadCallback + } + return listID, page, nil +} + +// ShareRef identifies either an active list share or a pending one. +type ShareRef struct { + ListID int64 + ShareID int64 + Pending bool +} + +func ShareAddData(listID int64) string { + return CallbackWithID(ActionShareAdd, listID) +} + +func ShareRoleData(listID int64, role domain.Role) string { + return encodeCallback(ActionShareRole, strconv.FormatInt(listID, 10), string(role)) +} + +func ShareViewData(ref ShareRef) string { + return encodeShareRef(ActionShareView, ref) +} + +func ShareSetData(ref ShareRef, role domain.Role) string { + args := append(shareRefArgs(ref), string(role)) + return encodeCallback(ActionShareSet, args...) +} + +func ShareRevokeData(ref ShareRef) string { + return encodeShareRef(ActionShareRevoke, ref) +} + +func ShareRevokeYesData(ref ShareRef) string { + return encodeShareRef(ActionShareRevokeYes, ref) +} + +func ParseShareRole(data string) (int64, domain.Role, error) { + args, err := callbackArgs(data, ActionShareRole, 2) + if err != nil { + return 0, "", err + } + listID, err := parsePositiveID(args[0]) + if err != nil { + return 0, "", err + } + role, ok := parseShareRole(args[1]) + if !ok { + return 0, "", ErrBadCallback + } + return listID, role, nil +} + +func ParseShareRef(data string, action CallbackAction) (ShareRef, error) { + args, err := callbackArgs(data, action, 3) + if err != nil { + return ShareRef{}, err + } + return parseShareRefArgs(args) +} + +func ParseShareSet(data string) (ShareRef, domain.Role, error) { + args, err := callbackArgs(data, ActionShareSet, 4) + if err != nil { + return ShareRef{}, "", err + } + ref, err := parseShareRefArgs(args[:3]) + if err != nil { + return ShareRef{}, "", err + } + role, ok := parseShareRole(args[3]) + if !ok { + return ShareRef{}, "", ErrBadCallback + } + return ref, role, nil +} + +func encodeCallback(action CallbackAction, args ...string) string { + return callbackVersion + ":" + string(action) + ":" + strings.Join(args, ":") +} + +func callbackArgs(data string, action CallbackAction, count int) ([]string, error) { + if len(data) == 0 || len(data) > MaxCallbackDataLength { + return nil, ErrBadCallback + } + raw, ok := strings.CutPrefix(data, CallbackPrefix(action)) + if !ok { + return nil, ErrBadCallback + } + args := strings.Split(raw, ":") + if len(args) != count { + return nil, ErrBadCallback + } + for _, arg := range args { + if !validCallbackArg(arg) { + return nil, ErrBadCallback + } + } + return args, nil +} + +func validCallbackArg(arg string) bool { + if len(arg) == 0 || len(arg) > 20 { + return false + } + for i := 0; i < len(arg); i++ { + char := arg[i] + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' { + continue + } + return false + } + return true +} + +func parsePositiveID(raw string) (int64, error) { + id, err := strconv.ParseInt(raw, 10, 64) if err != nil || id <= 0 { return 0, ErrBadCallback } return id, nil } +func shareRefArgs(ref ShareRef) []string { + kind := "u" + if ref.Pending { + kind = "p" + } + return []string{ + strconv.FormatInt(ref.ListID, 10), + kind, + strconv.FormatInt(ref.ShareID, 10), + } +} + +func encodeShareRef(action CallbackAction, ref ShareRef) string { + return encodeCallback(action, shareRefArgs(ref)...) +} + +func parseShareRefArgs(args []string) (ShareRef, error) { + listID, err := parsePositiveID(args[0]) + if err != nil { + return ShareRef{}, err + } + shareID, err := parsePositiveID(args[2]) + if err != nil { + return ShareRef{}, err + } + pending, ok := parseShareKind(args[1]) + if !ok { + return ShareRef{}, ErrBadCallback + } + return ShareRef{ListID: listID, ShareID: shareID, Pending: pending}, nil +} + +func parseShareKind(raw string) (bool, bool) { + switch raw { + case "u": + return false, true + case "p": + return true, true + default: + return false, false + } +} + +func parseShareRole(raw string) (domain.Role, bool) { + role := domain.Role(raw) + switch role { + case domain.RoleReader, domain.RoleEditor: + return role, true + default: + return "", false + } +} + // QueryChatID and QueryMessageID assume the callback was routed with // telegohandler.AnyCallbackQueryWithMessage. func QueryChatID(query telego.CallbackQuery) int64 { diff --git a/internal/telegram/shared/callback_test.go b/internal/telegram/shared/callback_test.go index c00d73b..f32fe9d 100644 --- a/internal/telegram/shared/callback_test.go +++ b/internal/telegram/shared/callback_test.go @@ -1,20 +1,30 @@ package shared -import "testing" +import ( + "strings" + "testing" + + "git.entityseven.com/rilaxik/pllist/internal/domain" +) func TestCallbackID(t *testing.T) { tests := []struct { name string data string - action string + action CallbackAction want int64 wantErr bool }{ - {name: "valid", data: "list:42", action: "list", want: 42}, - {name: "wrong prefix", data: "rename:42", action: "list", wantErr: true}, - {name: "not a number", data: "list:abc", action: "list", wantErr: true}, - {name: "zero", data: "list:0", action: "list", wantErr: true}, - {name: "missing id", data: "list", action: "list", wantErr: true}, + {name: "valid", data: "v1:open_list:42", action: ActionListOpen, want: 42}, + {name: "wrong action", data: "v1:rename_list:42", action: ActionListOpen, wantErr: true}, + {name: "wrong version", data: "v2:open_list:42", action: ActionListOpen, wantErr: true}, + {name: "unversioned", data: "open_list:42", action: ActionListOpen, wantErr: true}, + {name: "not a number", data: "v1:open_list:abc", action: ActionListOpen, wantErr: true}, + {name: "zero", data: "v1:open_list:0", action: ActionListOpen, wantErr: true}, + {name: "negative", data: "v1:open_list:-1", action: ActionListOpen, wantErr: true}, + {name: "missing id", data: "v1:open_list", action: ActionListOpen, wantErr: true}, + {name: "extra arg", data: "v1:open_list:42:1", action: ActionListOpen, wantErr: true}, + {name: "too long", data: "v1:open_list:" + strings.Repeat("1", MaxCallbackDataLength), action: ActionListOpen, wantErr: true}, } for _, tt := range tests { @@ -30,29 +40,24 @@ func TestCallbackID(t *testing.T) { } } -func TestCallbackWithIDRoundTrip(t *testing.T) { - for _, action := range []string{NavLists, NavNew, NavHelp, ListOpen, ListRename, ListDelete, ListDeleteYes} { - data := CallbackWithID(action, 7) - if data != action+":7" { - t.Fatalf("CallbackWithID() = %q", data) - } - } -} - func TestListDataRoundTrip(t *testing.T) { tests := []struct { name string data string - action string + action CallbackAction }{ - {name: "open", data: ListData(42), action: ListOpen}, - {name: "rename", data: RenameListData(42), action: ListRename}, - {name: "delete", data: DeleteListData(42), action: ListDelete}, - {name: "delete yes", data: DeleteListYesData(42), action: ListDeleteYes}, + {name: "open", data: ListData(42), action: ActionListOpen}, + {name: "rename", data: RenameListData(42), action: ActionListRename}, + {name: "delete", data: DeleteListData(42), action: ActionListDelete}, + {name: "delete yes", data: DeleteListYesData(42), action: ActionListDeleteYes}, + {name: "shares", data: SharesData(42), action: ActionShareManage}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if !strings.HasPrefix(tt.data, "v1:") { + t.Fatalf("callback data is not versioned: %q", tt.data) + } id, err := CallbackID(tt.data, tt.action) if err != nil { t.Fatalf("CallbackID(%q) error = %v", tt.data, err) @@ -63,3 +68,123 @@ func TestListDataRoundTrip(t *testing.T) { }) } } + +func TestPageCallbackRoundTrip(t *testing.T) { + data := PageData(42, 3) + listID, page, err := ParsePage(data) + if err != nil { + t.Fatalf("ParsePage(%q) error = %v", data, err) + } + if listID != 42 || page != 3 { + t.Fatalf("ParsePage(%q) = %d, %d", data, listID, page) + } +} + +func TestPageCallbackRejectsBadData(t *testing.T) { + for _, data := range []string{ + "v1:page", + "v1:page:42", + "v1:page:0:1", + "v1:page:42:0", + "v1:page:42:abc", + "v1:page:42:1:extra", + "page:42:1", + } { + if _, _, err := ParsePage(data); err == nil { + t.Fatalf("ParsePage(%q) accepted invalid data", data) + } + } +} + +func TestShareCallbackRoundTrips(t *testing.T) { + for _, pending := range []bool{false, true} { + ref := ShareRef{ListID: 42, ShareID: 7, Pending: pending} + for _, tt := range []struct { + name string + data string + action CallbackAction + }{ + {name: "view", data: ShareViewData(ref), action: ActionShareView}, + {name: "revoke", data: ShareRevokeData(ref), action: ActionShareRevoke}, + {name: "revoke yes", data: ShareRevokeYesData(ref), action: ActionShareRevokeYes}, + } { + got, err := ParseShareRef(tt.data, tt.action) + if err != nil { + t.Fatalf("%s ParseShareRef(%q) error = %v", tt.name, tt.data, err) + } + if got != ref { + t.Fatalf("%s ParseShareRef(%q) = %+v, want %+v", tt.name, tt.data, got, ref) + } + } + + got, role, err := ParseShareSet(ShareSetData(ref, domain.RoleReader)) + if err != nil || role != domain.RoleReader || got != ref { + t.Fatalf("ParseShareSet() = %+v, %q, %v", got, role, err) + } + } + + listID, role, err := ParseShareRole(ShareRoleData(42, domain.RoleEditor)) + if err != nil || listID != 42 || role != domain.RoleEditor { + t.Fatalf("ParseShareRole() = %d, %q, %v", listID, role, err) + } +} + +func TestShareCallbackRejectsBadData(t *testing.T) { + for _, data := range []string{ + "v1:share_role:42", + "v1:share_role:0:editor", + "v1:share_role:42:owner", + "v1:share_view:42:u", + "v1:share_view:42:x:7", + "v1:share_view:42:u:0", + "v1:share_set:42:u:7", + "v1:share_set:42:u:7:owner", + "v1:share_revoke:42:u", + "share_view:42:u:7", + } { + if !rejectsShareData(data) { + t.Fatalf("accepted invalid callback data %q", data) + } + } +} + +func rejectsShareData(data string) bool { + if _, _, err := ParseShareSet(data); err == nil { + return false + } + if _, err := ParseShareRef(data, ActionShareView); err == nil { + return false + } + if _, err := ParseShareRef(data, ActionShareRevoke); err == nil { + return false + } + if _, _, err := ParseShareRole(data); err == nil { + return false + } + return true +} + +func TestCallbackBuildersFitTelegramLimit(t *testing.T) { + ref := ShareRef{ListID: 9223372036854775807, ShareID: 9223372036854775807} + data := []string{ + NavLists, NavNew, NavHelp, + ListData(1 << 62), + RenameListData(1 << 62), + DeleteListData(1 << 62), + DeleteListYesData(1 << 62), + SharesData(1 << 62), + PageData(1<<62, 1<<30), + ShareAddData(1 << 62), + ShareRoleData(1<<62, domain.RoleEditor), + ShareViewData(ref), + ShareSetData(ref, domain.RoleEditor), + ShareRevokeData(ref), + ShareRevokeYesData(ref), + } + for _, item := range data { + if len(item) > MaxCallbackDataLength { + t.Fatalf("callback data %q is %d bytes, limit %d", + item, len(item), MaxCallbackDataLength) + } + } +} diff --git a/internal/telegram/shared/keyboard.go b/internal/telegram/shared/keyboard.go new file mode 100644 index 0000000..316b492 --- /dev/null +++ b/internal/telegram/shared/keyboard.go @@ -0,0 +1,22 @@ +package shared + +import ( + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// MyListsLabel is both the reply-keyboard button text and the message text +// Telegram sends when that button is pressed. +const MyListsLabel = "My lists" + +// MainKeyboard is the persistent keyboard shown below the input field. Reply +// keyboards are chat-wide, so sending it once keeps the button available. +func MainKeyboard() *telego.ReplyKeyboardMarkup { + return &telego.ReplyKeyboardMarkup{ + Keyboard: [][]telego.KeyboardButton{ + {tu.KeyboardButton(MyListsLabel)}, + }, + IsPersistent: true, + ResizeKeyboard: true, + } +} diff --git a/internal/telegram/shared/keyboard_test.go b/internal/telegram/shared/keyboard_test.go new file mode 100644 index 0000000..342ea53 --- /dev/null +++ b/internal/telegram/shared/keyboard_test.go @@ -0,0 +1,19 @@ +package shared + +import "testing" + +func TestMainKeyboardHasMyListsButton(t *testing.T) { + keyboard := MainKeyboard() + if !keyboard.IsPersistent { + t.Fatal("MainKeyboard() is not persistent") + } + if !keyboard.ResizeKeyboard { + t.Fatal("MainKeyboard() does not resize") + } + if len(keyboard.Keyboard) != 1 || len(keyboard.Keyboard[0]) != 1 { + t.Fatalf("MainKeyboard() = %v, want a single row with one button", keyboard.Keyboard) + } + if got := keyboard.Keyboard[0][0].Text; got != MyListsLabel { + t.Fatalf("button text = %q, want %q", got, MyListsLabel) + } +} diff --git a/internal/telegram/shared/sender.go b/internal/telegram/shared/sender.go index 91d7a69..c0ccebd 100644 --- a/internal/telegram/shared/sender.go +++ b/internal/telegram/shared/sender.go @@ -18,7 +18,7 @@ func NewSender(api *telego.Bot) *Sender { } func (s *Sender) Send( - ctx context.Context, chatID int64, text string, keyboard *telego.InlineKeyboardMarkup, + ctx context.Context, chatID int64, text string, keyboard telego.ReplyMarkup, ) error { _, err := s.api.SendMessage(ctx, sendParams(chatID, text, keyboard)) if err != nil { @@ -40,14 +40,13 @@ func (s *Sender) EditOrSend( return nil } -// A typed nil pointer stored in the ReplyMarkup interface would marshal as -// null, which Telegram rejects. Only set it when a keyboard exists. +// Only set reply_markup when a keyboard exists; Telegram rejects null. func sendParams( - chatID int64, text string, keyboard *telego.InlineKeyboardMarkup, + chatID int64, text string, keyboard telego.ReplyMarkup, ) *telego.SendMessageParams { params := &telego.SendMessageParams{ ChatID: tu.ID(chatID), - Text: text, + Text: FitMessage(text), ParseMode: telego.ModeHTML, } if keyboard != nil { @@ -62,7 +61,7 @@ func editParams( params := &telego.EditMessageTextParams{ ChatID: tu.ID(chatID), MessageID: messageID, - Text: text, + Text: FitMessage(text), ParseMode: telego.ModeHTML, } if keyboard != nil { diff --git a/internal/telegram/shared/sender_test.go b/internal/telegram/shared/sender_test.go index 5020f07..fcc404e 100644 --- a/internal/telegram/shared/sender_test.go +++ b/internal/telegram/shared/sender_test.go @@ -26,6 +26,14 @@ func TestSendParamsOmitNilKeyboard(t *testing.T) { if !strings.Contains(string(raw), "reply_markup") { t.Fatalf("send params missing reply_markup: %s", raw) } + + raw, err = json.Marshal(sendParams(1, "hello", MainKeyboard())) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if !strings.Contains(string(raw), `"keyboard"`) { + t.Fatalf("send params missing reply keyboard: %s", raw) + } } func TestEditParamsOmitNilKeyboard(t *testing.T) { diff --git a/internal/telegram/shared/text.go b/internal/telegram/shared/text.go index c43942f..a8f7e4f 100644 --- a/internal/telegram/shared/text.go +++ b/internal/telegram/shared/text.go @@ -27,3 +27,17 @@ func UserMessage(err error) string { return "Something went wrong. Try again." } } + +// IsExpectedError reports whether an error carries a user-facing explanation +// rather than an internal failure. +func IsExpectedError(err error) bool { + var validation domain.ValidationError + if errors.As(err, &validation) { + return true + } + return errors.Is(err, domain.ErrNotFound) || + errors.Is(err, domain.ErrForbidden) || + errors.Is(err, domain.ErrConflict) || + errors.Is(err, domain.ErrInvalidInput) || + errors.Is(err, ErrBadCallback) +} diff --git a/internal/telegram/shared/text_test.go b/internal/telegram/shared/text_test.go index 528a1ea..75d7ab2 100644 --- a/internal/telegram/shared/text_test.go +++ b/internal/telegram/shared/text_test.go @@ -29,3 +29,22 @@ func TestUserMessage(t *testing.T) { }) } } + +func TestIsExpectedError(t *testing.T) { + expected := []error{ + domain.NewValidationError("bad"), + domain.ErrNotFound, + domain.ErrForbidden, + domain.ErrConflict, + domain.ErrInvalidInput, + ErrBadCallback, + } + for _, err := range expected { + if !IsExpectedError(err) { + t.Fatalf("IsExpectedError(%v) = false, want true", err) + } + } + if IsExpectedError(errors.New("database down")) { + t.Fatal("IsExpectedError() = true for an unexpected error") + } +} diff --git a/internal/telegram/shared/ui.go b/internal/telegram/shared/ui.go index fbf9da2..418baf8 100644 --- a/internal/telegram/shared/ui.go +++ b/internal/telegram/shared/ui.go @@ -25,3 +25,110 @@ func Truncate(text string, max int) string { runes := []rune(text) return string(runes[:max-1]) + "…" } + +// TelegramMessageLimit is Telegram's maximum text length in characters. +const TelegramMessageLimit = 4096 + +// FitMessage truncates rendered HTML to the Telegram message limit without +// cutting an entity or leaving a tag open. +func FitMessage(text string) string { + runes := []rune(text) + if len(runes) <= TelegramMessageLimit { + return text + } + + limit := TelegramMessageLimit - 1 // reserve the ellipsis + cut := safeCut(runes, limit) + for cut > 0 { + closers := closingTags(runes[:cut]) + if cut+utf8.RuneCountInString(closers) <= limit { + return string(runes[:cut]) + closers + "…" + } + cut = safeCut(runes, cut-1) + } + return "…" +} + +// safeCut returns the largest index up to limit that is not inside an HTML tag +// or entity. +func safeCut(runes []rune, limit int) int { + if limit > len(runes) { + limit = len(runes) + } + inTag := false + entityStart := -1 + for i := 0; i < limit; i++ { + char := runes[i] + switch { + case inTag: + if char == '>' { + inTag = false + } + case entityStart >= 0: + if char == ';' { + entityStart = -1 + } + case char == '<': + inTag = true + case char == '&': + entityStart = i + } + } + if inTag { + for i := limit - 1; i >= 0; i-- { + if runes[i] == '<' { + return i + } + } + return 0 + } + if entityStart >= 0 { + return entityStart + } + return limit +} + +// closingTags returns the closing tags needed for any HTML left open by prefix. +func closingTags(prefix []rune) string { + var open []string + for i := 0; i < len(prefix); i++ { + if prefix[i] != '<' { + continue + } + end := i + 1 + for end < len(prefix) && prefix[end] != '>' { + end++ + } + if end >= len(prefix) { + break + } + raw := strings.TrimSpace(string(prefix[i+1 : end])) + i = end + if raw == "" { + continue + } + if strings.HasPrefix(raw, "/") { + name := strings.TrimSpace(raw[1:]) + if len(open) > 0 && open[len(open)-1] == name { + open = open[:len(open)-1] + } + continue + } + name := raw + if space := strings.IndexAny(name, " \t\n"); space >= 0 { + name = name[:space] + } + if strings.HasSuffix(name, "/") || name == "br" { + continue + } + open = append(open, name) + } + + var closers strings.Builder + for i := len(open) - 1; i >= 0; i-- { + closers.WriteString("') + } + return closers.String() +} diff --git a/internal/telegram/shared/ui_test.go b/internal/telegram/shared/ui_test.go new file mode 100644 index 0000000..25a13ba --- /dev/null +++ b/internal/telegram/shared/ui_test.go @@ -0,0 +1,64 @@ +package shared + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestEscape(t *testing.T) { + if got := Escape(`Milk & eggs`); got != "<b>Milk & eggs</b>" { + t.Fatalf("Escape() = %q", got) + } +} + +func TestFitMessageLeavesShortText(t *testing.T) { + text := "Milk & eggs" + if got := FitMessage(text); got != text { + t.Fatalf("FitMessage() = %q, want unchanged", got) + } +} + +func TestFitMessageTruncatesPlainText(t *testing.T) { + got := FitMessage(strings.Repeat("a", TelegramMessageLimit+100)) + if utf8.RuneCountInString(got) > TelegramMessageLimit { + t.Fatalf("FitMessage() is %d runes, limit %d", + utf8.RuneCountInString(got), TelegramMessageLimit) + } + if !strings.HasSuffix(got, "…") { + t.Fatal("FitMessage() does not end with an ellipsis") + } +} + +func TestFitMessageClosesOpenTags(t *testing.T) { + got := FitMessage("" + strings.Repeat("a", TelegramMessageLimit)) + if utf8.RuneCountInString(got) > TelegramMessageLimit { + t.Fatalf("FitMessage() is %d runes, limit %d", + utf8.RuneCountInString(got), TelegramMessageLimit) + } + if !strings.HasSuffix(got, "…") { + t.Fatalf("FitMessage() does not close : %q", got[len(got)-20:]) + } +} + +func TestFitMessageDoesNotSplitEntity(t *testing.T) { + got := FitMessage(strings.Repeat("a", TelegramMessageLimit-2) + "&" + "tail") + if utf8.RuneCountInString(got) > TelegramMessageLimit { + t.Fatalf("FitMessage() is %d runes, limit %d", + utf8.RuneCountInString(got), TelegramMessageLimit) + } + if strings.Contains(got, "&am…") || strings.HasSuffix(got, "&") { + t.Fatalf("FitMessage() split an entity: %q", got[len(got)-10:]) + } +} + +func TestFitMessageDoesNotSplitTag(t *testing.T) { + got := FitMessage(strings.Repeat("a", TelegramMessageLimit-3) + "tail") + if strings.HasSuffix(got, "<") || strings.HasSuffix(got, " TelegramMessageLimit { + t.Fatalf("FitMessage() is %d runes, limit %d", + utf8.RuneCountInString(got), TelegramMessageLimit) + } +} -- 2.54.0 From 0b716e8f250347f9a723a96696fa618be00c79c6 Mon Sep 17 00:00:00 2001 From: Danya H Date: Sun, 8 Nov 2026 03:52:49 +0000 Subject: [PATCH 9/9] feat: roadmap polish --- .gitea/workflows/ci.yml | 15 ++++ cmd/bot/main.go | 4 +- dockerfile | 16 ++-- docs/infra.md | 4 +- docs/roadmap-mvp.md | 6 +- internal/domain/item_test.go | 5 ++ internal/service/access_test.go | 9 ++ internal/service/item_service_test.go | 113 ++++++++++++++++++++++++-- internal/telegram/caller.go | 29 +++++++ internal/telegram/caller_test.go | 72 ++++++++++++++++ 10 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 internal/telegram/caller.go create mode 100644 internal/telegram/caller_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ccb885a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,15 @@ +name: ci +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.27" + - run: test -z "$(gofmt -l .)" + - run: go vet ./... + - run: go test ./... + - run: go build ./... diff --git a/cmd/bot/main.go b/cmd/bot/main.go index ed3fa75..55cc0a0 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -56,7 +56,9 @@ func run(logger *slog.Logger) error { } } - tgBot, err := telego.NewBot(cfg.TelegramBotToken) + tgBot, err := telego.NewBot( + cfg.TelegramBotToken, telego.WithAPICaller(telegram.NewCaller()), + ) if err != nil { return fmt.Errorf("create bot: %w", err) } diff --git a/dockerfile b/dockerfile index 647d772..87427b1 100644 --- a/dockerfile +++ b/dockerfile @@ -1,13 +1,17 @@ # Build stage FROM golang:1.27-alpine AS builder -WORKDIR /app +WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o /bot cmd/bot/main.go +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/bot ./cmd/bot # Run stage -FROM alpine:latest -WORKDIR /root/ -COPY --from=builder /bot . -CMD ["./bot"] +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates \ + && addgroup -S app \ + && adduser -S -G app -h /app app +WORKDIR /app +COPY --from=builder --chown=app:app /out/bot /app/bot +USER app +ENTRYPOINT ["/app/bot"] diff --git a/docs/infra.md b/docs/infra.md index 47607f1..6b1c496 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -73,8 +73,8 @@ Use a separate bot token for development. ## Image `dockerfile` is a multi-stage build: compile a static binary, then run it on -Alpine. It must keep `ca-certificates` installed because the bot calls the -Telegram API over TLS. +Alpine as an unprivileged `app` user. It keeps `ca-certificates` installed +because the bot calls the Telegram API over TLS. ```sh docker build -t pllist-bot . diff --git a/docs/roadmap-mvp.md b/docs/roadmap-mvp.md index bf17a7b..facecbe 100644 --- a/docs/roadmap-mvp.md +++ b/docs/roadmap-mvp.md @@ -54,12 +54,10 @@ ## Before production -- Add `ca-certificates` and a non-root user to the runtime image. - Add versioned SQL migrations; stop relying on GORM automigrate. -- Add unit tests for domain transitions and the access matrix. -- Add the Gitea Actions workflow. +- Add PostgreSQL-backed tests for the conditional item transitions (done, + reopen, reserve, release) in the repository. - Enable PostgreSQL backups in Dokploy. -- Add `429 retry_after` handling. ## Later diff --git a/internal/domain/item_test.go b/internal/domain/item_test.go index 2db6944..d420110 100644 --- a/internal/domain/item_test.go +++ b/internal/domain/item_test.go @@ -24,6 +24,11 @@ func TestAllowed(t *testing.T) { action: ActionDeleteList, want: map[Role]bool{RoleOwner: true, RoleEditor: false, RoleReader: false}, }, + { + name: "manage shares", + action: ActionManageShares, + want: map[Role]bool{RoleOwner: true, RoleEditor: false, RoleReader: false}, + }, { name: "view items", action: ActionViewItems, diff --git a/internal/service/access_test.go b/internal/service/access_test.go index 9a2454e..727462f 100644 --- a/internal/service/access_test.go +++ b/internal/service/access_test.go @@ -49,6 +49,11 @@ func TestAuthorize(t *testing.T) { {name: "owner views", userID: 1, listID: listID, action: domain.ActionViewList}, {name: "owner renames", userID: 1, listID: listID, action: domain.ActionEditListMeta}, {name: "owner deletes", userID: 1, listID: listID, action: domain.ActionDeleteList}, + {name: "owner views items", userID: 1, listID: listID, action: domain.ActionViewItems}, + {name: "owner edits items", userID: 1, listID: listID, action: domain.ActionEditItem}, + {name: "owner deletes items", userID: 1, listID: listID, action: domain.ActionDeleteItem}, + {name: "owner changes item state", userID: 1, listID: listID, action: domain.ActionChangeItemState}, + {name: "owner manages shares", userID: 1, listID: listID, action: domain.ActionManageShares}, {name: "owner adds items", userID: 1, listID: listID, action: domain.ActionAddItem}, {name: "owner reserves", userID: 1, listID: listID, action: domain.ActionReserveItem}, {name: "editor views", userID: 2, listID: listID, action: domain.ActionViewList}, @@ -60,12 +65,16 @@ func TestAuthorize(t *testing.T) { {name: "editor reserves", userID: 2, listID: listID, action: domain.ActionReserveItem}, {name: "editor cannot rename", userID: 2, listID: listID, action: domain.ActionEditListMeta, wantErr: domain.ErrForbidden}, {name: "editor cannot delete", userID: 2, listID: listID, action: domain.ActionDeleteList, wantErr: domain.ErrForbidden}, + {name: "editor cannot manage shares", userID: 2, listID: listID, action: domain.ActionManageShares, wantErr: domain.ErrForbidden}, {name: "reader views", userID: 3, listID: listID, action: domain.ActionViewList}, {name: "reader views items", userID: 3, listID: listID, action: domain.ActionViewItems}, {name: "reader cannot add items", userID: 3, listID: listID, action: domain.ActionAddItem, wantErr: domain.ErrForbidden}, {name: "reader cannot edit items", userID: 3, listID: listID, action: domain.ActionEditItem, wantErr: domain.ErrForbidden}, + {name: "reader cannot delete items", userID: 3, listID: listID, action: domain.ActionDeleteItem, wantErr: domain.ErrForbidden}, + {name: "reader cannot change item state", userID: 3, listID: listID, action: domain.ActionChangeItemState, wantErr: domain.ErrForbidden}, {name: "reader cannot reserve", userID: 3, listID: listID, action: domain.ActionReserveItem, wantErr: domain.ErrForbidden}, {name: "reader cannot rename", userID: 3, listID: listID, action: domain.ActionEditListMeta, wantErr: domain.ErrForbidden}, + {name: "reader cannot manage shares", userID: 3, listID: listID, action: domain.ActionManageShares, wantErr: domain.ErrForbidden}, {name: "stranger gets not found", userID: 4, listID: listID, action: domain.ActionViewList, wantErr: domain.ErrNotFound}, {name: "stranger cannot view items", userID: 4, listID: listID, action: domain.ActionViewItems, wantErr: domain.ErrNotFound}, {name: "missing list", userID: 1, listID: 99, action: domain.ActionViewList, wantErr: domain.ErrNotFound}, diff --git a/internal/service/item_service_test.go b/internal/service/item_service_test.go index 4c8b7bc..aedff19 100644 --- a/internal/service/item_service_test.go +++ b/internal/service/item_service_test.go @@ -16,6 +16,9 @@ type fakeItemRepository struct { lastLimit int lastOffset int lastReserveUser int64 + transition string + transitionID int64 + transitionErr error } func (f *fakeItemRepository) Create( @@ -66,16 +69,28 @@ func (f *fakeItemRepository) UpdateUrgency( func (f *fakeItemRepository) Delete(_ context.Context, _ int64) error { return nil } -func (f *fakeItemRepository) SetDone(_ context.Context, _ int64) error { return nil } - -func (f *fakeItemRepository) SetOpen(_ context.Context, _ int64) error { return nil } - -func (f *fakeItemRepository) Reserve(_ context.Context, _, userID int64) error { - f.lastReserveUser = userID - return nil +func (f *fakeItemRepository) SetDone(_ context.Context, id int64) error { + return f.transitionTo("done", id, 0) } -func (f *fakeItemRepository) Release(_ context.Context, _ int64) error { return nil } +func (f *fakeItemRepository) SetOpen(_ context.Context, id int64) error { + return f.transitionTo("reopen", id, 0) +} + +func (f *fakeItemRepository) Reserve(_ context.Context, id, userID int64) error { + return f.transitionTo("reserve", id, userID) +} + +func (f *fakeItemRepository) Release(_ context.Context, id int64) error { + return f.transitionTo("release", id, 0) +} + +func (f *fakeItemRepository) transitionTo(action string, id, userID int64) error { + f.transition = action + f.transitionID = id + f.lastReserveUser = userID + return f.transitionErr +} func newTestItemService() (*ItemService, *fakeItemRepository) { const listID = int64(10) @@ -164,3 +179,85 @@ func TestItemServiceReserveAuthorization(t *testing.T) { t.Fatalf("Reserve() user = %d, want 2", itemRepo.lastReserveUser) } } + +func TestItemServiceStateTransitions(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + call func(*ItemService) error + want string + wantUser int64 + }{ + { + name: "owner marks done", + call: func(s *ItemService) error { return s.SetDone(ctx, 1, 5) }, + want: "done", + }, + { + name: "editor reopens", + call: func(s *ItemService) error { return s.Reopen(ctx, 2, 5) }, + want: "reopen", + }, + { + name: "editor reserves", + call: func(s *ItemService) error { return s.Reserve(ctx, 2, 5) }, + want: "reserve", + wantUser: 2, + }, + { + name: "editor releases", + call: func(s *ItemService) error { return s.Release(ctx, 2, 5) }, + want: "release", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + items, repo := newTestItemService() + if err := tt.call(items); err != nil { + t.Fatalf("transition error = %v", err) + } + if repo.transition != tt.want || repo.transitionID != 5 { + t.Fatalf("repository transition = %q/%d, want %q/5", + repo.transition, repo.transitionID, tt.want) + } + if repo.lastReserveUser != tt.wantUser { + t.Fatalf("repository user = %d, want %d", repo.lastReserveUser, tt.wantUser) + } + }) + } +} + +func TestItemServiceTransitionsRejectReaders(t *testing.T) { + ctx := context.Background() + calls := []struct { + name string + call func(*ItemService) error + }{ + {name: "done", call: func(s *ItemService) error { return s.SetDone(ctx, 3, 5) }}, + {name: "reopen", call: func(s *ItemService) error { return s.Reopen(ctx, 3, 5) }}, + {name: "reserve", call: func(s *ItemService) error { return s.Reserve(ctx, 3, 5) }}, + {name: "release", call: func(s *ItemService) error { return s.Release(ctx, 3, 5) }}, + } + + for _, tt := range calls { + t.Run(tt.name, func(t *testing.T) { + items, repo := newTestItemService() + if err := tt.call(items); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("transition error = %v, want ErrForbidden", err) + } + if repo.transition != "" { + t.Fatalf("repository transition = %q, want no call", repo.transition) + } + }) + } +} + +func TestItemServiceTransitionConflict(t *testing.T) { + items, repo := newTestItemService() + repo.transitionErr = domain.ErrConflict + + if err := items.SetDone(context.Background(), 2, 5); !errors.Is(err, domain.ErrConflict) { + t.Fatalf("SetDone() error = %v, want ErrConflict", err) + } +} diff --git a/internal/telegram/caller.go b/internal/telegram/caller.go new file mode 100644 index 0000000..92f2da8 --- /dev/null +++ b/internal/telegram/caller.go @@ -0,0 +1,29 @@ +package telegram + +import ( + "time" + + ta "github.com/mymmrac/telego/telegoapi" +) + +const ( + apiMaxAttempts = 3 + apiMaxDelay = 30 * time.Second +) + +// NewCaller returns the Telegram API caller. It retries transient failures and +// honors 429 retry_after, waiting only when the requested delay fits apiMaxDelay. +func NewCaller() ta.Caller { + return newCaller() +} + +func newCaller() *ta.RetryCaller { + return &ta.RetryCaller{ + Caller: ta.DefaultFastHTTPCaller, + MaxAttempts: apiMaxAttempts, + ExponentBase: 2, + StartDelay: time.Second, + MaxDelay: apiMaxDelay, + RateLimit: ta.RetryRateLimitWaitOrAbort, + } +} diff --git a/internal/telegram/caller_test.go b/internal/telegram/caller_test.go new file mode 100644 index 0000000..060137b --- /dev/null +++ b/internal/telegram/caller_test.go @@ -0,0 +1,72 @@ +package telegram + +import ( + "context" + "testing" + "time" + + ta "github.com/mymmrac/telego/telegoapi" +) + +func TestNewCallerHandlesRateLimits(t *testing.T) { + caller := newCaller() + + if caller.RateLimit != ta.RetryRateLimitWaitOrAbort { + t.Fatalf("RateLimit = %v, want RetryRateLimitWaitOrAbort", caller.RateLimit) + } + if caller.MaxAttempts < 2 { + t.Fatalf("MaxAttempts = %d, want at least 2", caller.MaxAttempts) + } + if caller.MaxDelay != apiMaxDelay || caller.MaxDelay <= 0 { + t.Fatalf("MaxDelay = %v, want %v", caller.MaxDelay, apiMaxDelay) + } + if caller.StartDelay <= 0 || caller.StartDelay > caller.MaxDelay { + t.Fatalf("StartDelay = %v, MaxDelay = %v", caller.StartDelay, caller.MaxDelay) + } + if caller.ExponentBase <= 1 { + t.Fatalf("ExponentBase = %v, want > 1", caller.ExponentBase) + } + if caller.Caller == nil { + t.Fatal("Caller is nil") + } +} + +func TestRetryCallerWaitsForRetryAfter(t *testing.T) { + requests := 0 + inner := callerFunc(func() error { + requests++ + return &ta.Error{ + ErrorCode: 429, + Parameters: &ta.ResponseParameters{ + RetryAfter: 1, + }, + } + }) + caller := &ta.RetryCaller{ + Caller: inner, + MaxAttempts: 2, + ExponentBase: 2, + StartDelay: time.Millisecond, + MaxDelay: time.Second, + RateLimit: ta.RetryRateLimitWaitOrAbort, + } + + _, err := caller.Call(t.Context(), "https://example.invalid", &ta.RequestData{BodyRaw: []byte("{}")}) + if err == nil { + t.Fatal("Call() error = nil, want retry exhaustion") + } + if requests != 2 { + t.Fatalf("Call() made %d requests, want 2", requests) + } +} + +type callerFunc func() error + +func (f callerFunc) Call( + _ context.Context, _ string, _ *ta.RequestData, +) (*ta.Response, error) { + if err := f(); err != nil { + return nil, err + } + return &ta.Response{Ok: true}, nil +} -- 2.54.0