diff --git a/.env.example b/.env.example index 4cd3d66..de5c28b 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,11 @@ MONGO_URL=mongodb+srv://botuser:STRONG_UNIQUE_PASSWORD@cluster0.xxxxx.mongodb.ne MONGO_DATABASE=miti99bot # ============================ Operational ========================= -# Comma-separated module list. Empty = load every module. -MODULES= +# Comma-separated module list. Empty = load every module, including any module +# added later — so list them explicitly when a deployment should only gain a new +# module deliberately. `sticker` creates real, durable Telegram sticker sets on +# behalf of users, which is worth enabling on purpose rather than by default. +MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker # Telegram user id for owner-only commands (renamed from BOT_OWNER_ID). OWNER_ID= # Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS). diff --git a/README.md b/README.md index b579e60..4824cec 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Atlas via long polling and an in-process cron scheduler. | `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) | | `stats` | `/stats` (top commands), `/stats users`, `/stats user `, `/stats cmd ` | | `monkeyd` | `/monkeyd_crawl [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags ` list its tags as hashtags | +| `sticker` | One personal sticker pack per user: `/newpack`, `/mypack`, `/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`, `/renamepack`, `/delpack`. See [docs/sticker-packs.md](docs/sticker-packs.md) | Disable modules with the `MODULES` environment variable. diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 38e6318..a8d20f1 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -72,6 +72,15 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "monkeyd_crawl": " [font_size]", "monkeyd_tags": "", "random": "", + "newpack": " ", + "mypack": "", + "addsticker": "[emoji...]", + "delsticker": "", + "editsticker": "", + "ordersticker": "", + "setpackicon": "", + "renamepack": "", + "delpack": "", "stats": "[users | user | cmd ]", "stock_events": " [days]", "stock_info": "", @@ -91,7 +100,11 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { if len(menu) != len(reg.PublicCommands()) { t.Fatalf("menu commands = %d, public commands = %d", len(menu), len(reg.PublicCommands())) } + // This map lists the commands whose parameter strings are pinned; commands + // absent from it are not asserted (the lookup yields "" for them). + seen := map[string]bool{} for _, command := range reg.PublicCommands() { + seen[command.Name] = true if got := command.Parameters; got != expectedParameters[command.Name] { t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name]) } @@ -107,6 +120,20 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { } } + // Every expectation must correspond to a registered command. + // + // Without this, an entry whose command stopped being registered simply + // stopped being checked: the forward loop only visits commands that exist, + // so removing a whole module from factories() left the suite green. That is + // also what makes the parameterless entries above load-bearing rather than + // decorative — "" == "" asserts nothing on its own, but the command having + // to exist at all does. + for name := range expectedParameters { + if !seen[name] { + t.Errorf("/%s is expected but not registered — a module dropped out of factories()", name) + } + } + help := moduleutil.RenderHelp(reg) if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest { t.Fatalf("/help source is %d characters, exceeds conservative Telegram limit %d", utf8.RuneCountInString(help), telegramMessageMaxRunesForTest) diff --git a/cmd/server/main.go b/cmd/server/main.go index 7acf76e..fe3b13a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,6 +26,7 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/misc" "github.com/tiennm99/miti99bot/internal/modules/monkeyd" "github.com/tiennm99/miti99bot/internal/modules/stats" + "github.com/tiennm99/miti99bot/internal/modules/sticker" "github.com/tiennm99/miti99bot/internal/modules/stock" "github.com/tiennm99/miti99bot/internal/modules/util" "github.com/tiennm99/miti99bot/internal/modules/wordle" @@ -91,6 +92,7 @@ func factories() map[string]modules.Factory { "gold": gold.New, stock.CollectionName: stock.New, "stats": stats.New, + "sticker": sticker.New, } } diff --git a/docs/command-parameter-conventions.md b/docs/command-parameter-conventions.md index 61a79e9..897892f 100644 --- a/docs/command-parameter-conventions.md +++ b/docs/command-parameter-conventions.md @@ -15,6 +15,7 @@ remain responsible for parsing and validation. |---|---|---| | Required value | `` | `` | | Required comma-separated values | `` | `` | +| Required remaining text | `` | `` | | Optional value | `[name]` | `[date]` | | Optional remaining text | `[name...]` | `[target...]` | | Alternatives in an optional group | `[literal | literal ]` | `[users | user ]` | @@ -40,6 +41,7 @@ language or extra punctuation without a user-facing need. ```text /stock_buy +/renamepack /lol [date] /trongtruonghop [target...] /stats [users | user | cmd ] diff --git a/docs/sticker-packs.md b/docs/sticker-packs.md new file mode 100644 index 0000000..a982ab3 --- /dev/null +++ b/docs/sticker-packs.md @@ -0,0 +1,158 @@ +# Sticker packs + +The `sticker` module lets any user create and manage **one** personal Telegram +sticker pack through the bot. The pack is created on behalf of the calling user, +so it appears under their account, and it stays bot-manageable because the bot +created it. + +Every command is single-shot: one message carrying its arguments, optionally +replying to a sticker or photo. There is no conversation state and no `/cancel`. + +## Commands + +| Command | Parameters | Reply to | What it does | +|---|---|---|---| +| `/newpack` | ` ` | sticker or photo | Creates your pack and returns its share link | +| `/mypack` | — | — | Shows your pack: name, title, sticker count, link | +| `/addsticker` | `[emoji...]` | sticker or photo | Adds it to your pack | +| `/delsticker` | — | a sticker in your pack | Removes it | +| `/editsticker` | `` | a sticker in your pack | Replaces that sticker's emoji | +| `/ordersticker` | `` | a sticker in your pack | Moves it; positions start at 0 | +| `/setpackicon` | — | a sticker in your pack | Uses it as the pack icon | +| `/renamepack` | `` | — | Changes the displayed title | +| `/delpack` | — | — | Deletes the pack, after an inline confirmation | + +Only `/newpack` names a pack. Every other command resolves your single pack from +storage, or from the replied sticker's set. + +## The pack name is permanent + +`/newpack mypack My Pack` creates `t.me/addstickers/mypack_by_`. + +**Telegram has no method to rename a sticker set's short name.** That link is +fixed for the life of the pack. `/renamepack` changes only the displayed title. + +The only way to a different link is `/delpack` followed by `/newpack` under a new +name — and the stickers do not come along. `/delpack`'s confirmation states the +title, the number of stickers it destroys, the exact link being surrendered, and +that both are permanent, because that prompt is the last point at which someone +wanting "a rename" learns what it actually costs. + +Pack-name rules: 3–40 characters, lowercase letters, digits and underscores, +starting with a letter, no two underscores in a row, no trailing underscore. + +## Images + +Static stickers only. Animated, video, mask, and custom-emoji stickers are +rejected. + +- A replied **sticker** is added directly. +- A replied **photo** or image **document** (`image/png`, `image/jpeg`, + `image/webp`) is downloaded, resized so its long edge is exactly 512px with + the aspect ratio preserved, and uploaded as a PNG. +- Pack icons are resized to exactly 100×100, padded transparently. +- Sources above 2 MB, or with either side above 4096px, are rejected. + +Telegram allows 120 stickers per pack and 1–20 emoji per sticker. There is no +documented file-size limit for static stickers; the module applies its own +client-side ceiling and never presents it as a Telegram rule. + +## Who can use it + +Public — every user manages their own pack. + +**Anonymous group admins are refused.** Telegram substitutes a single global +`GroupAnonymousBot` user for every anonymous admin message, so without this +refusal all anonymous admins across all groups would share one pack. Turn off +anonymous posting for the message and try again. + +## Deliberate omissions + +- **Usage statistics** (`/stats`, `/top`, `/packstats`, …) — the Bot API does + not expose sticker usage counts, and `/stats` belongs to the `stats` module. +- **Animated, video, emoji, and mask packs** — out of scope; this module is + static-only. +- **More than one pack per user** — a deliberate simplification. It is what lets + every command but `/newpack` drop its pack argument. +- **`/cancel`** — meaningless without conversation state. +- **A `/repack` migration command** — copying a full pack is up to ~121 + sequential API calls, which would stall the bot for every user. + +## Behaviour worth knowing + +**Sticker counts are advisory.** `/mypack` reads the count from storage and +makes no API calls at all. Editing your pack through @Stickers changes the real +count without the bot seeing it; the number re-syncs whenever a command already +has a fresh view of the set. + +**Pack names are claimed first-come and held permanently.** The bot records who +claimed each name before it creates anything on Telegram, and only that user can +ever manage a pack under it. This is what stops someone from reading a pack's +name off its public link and taking it over. + +`/newpack` therefore reports when a name is taken, which reveals that some user +of this bot holds it. That is accepted: `t.me/addstickers/_by_` is +publicly probeable without the bot, so the command discloses nothing new. It +never says *who* holds a name. Refusals about *managing* a pack are deliberately +uniform for the opposite reason — see below. + +A name is claimed *before* the bot calls Telegram, not after the pack exists. +That ordering is what keeps two users from racing for the same name: the first +claimant wins it and everyone else is refused before any set is created. + +The claim is given up again whenever the bot has positive evidence that no pack +stands behind it — Telegram refusing the creation outright, `/delpack`, or a +later command finding the set already gone. A `/newpack` that never got as far +as claiming, or that is refused before Telegram is contacted, leaves nothing +behind. + +The claim is deliberately **not** treated as proof of ownership over a set that +already exists. See "The bot never takes over an existing pack" below. + +Telegram may keep a deleted short name reserved on its own side, so a freed name +is not guaranteed to be usable again by anyone, including its previous owner. + +**Ownership refusals are identical by design.** "You don't have a pack" and +"that sticker isn't from your pack" produce the exact same reply. Distinct +wording would let anyone probe which sets exist under this bot. + +**The bot never takes over an existing pack.** If a set already exists under the +name you ask for, `/newpack` refuses — always, for everyone, whatever the bot's +records say about it. + +Earlier versions adopted such a set when local records suggested it came from +your own interrupted attempt. That was wrong in a way no amount of checking +fixes: every fact the bot could use to prove "this set is yours" lives in the +same storage a restart erases, while the packs at Telegram survive. Once the +proof is gone, a genuine interrupted attempt and a stranger naming your pack's +public link present the bot with identical evidence. The feature was the hole, +so the feature is gone. + +The cost is real and worth stating plainly: if the bot crashes between creating +your set at Telegram and recording it, the set is stranded. It exists, it is +linkable, and no command in this bot can manage or delete it. `/mypack` marks +the unfinished attempt, and `/delpack` clears the leftover record so you can +create a pack under a different name — but it will not delete anything at +Telegram, because an unfinished record is not evidence that the bot made that +set for you. Anyone can produce such a record for any name. + +Run this module against a real database. On the in-memory backend every restart +strands every pack. + +**Deleting the last sticker may delete the pack.** Telegram's behaviour here is +undocumented, so the bot does not guess: it will not remove your pack record on +anything less than a positive "this set no longer exists" from Telegram. If a +command reports the pack is gone, `/delpack` clears the stale record and +`/newpack` works again. + +## Operations + +The module is enabled by listing `sticker` in `MODULES` (an empty `MODULES` +loads every module). It stores one record per user, keyed by Telegram user ID, +plus at most one pending `/delpack` confirmation per user — running `/delpack` +again supersedes the previous prompt, and a confirmation stops working after 10 +minutes. + +Every handler runs under a 10-second deadline. The bot processes updates one at +a time, so this bound is what keeps an image conversion from stalling other +users. diff --git a/go.mod b/go.mod index 5a2fd1f..fd4e24c 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/mongodb v0.43.0 github.com/tiennm99/monkeyd-crawler v0.0.0 go.mongodb.org/mongo-driver/v2 v2.7.0 + golang.org/x/image v0.45.0 ) require ( @@ -71,7 +72,7 @@ require ( golang.org/x/crypto v0.54.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3da38c3..79147bd 100644 --- a/go.sum +++ b/go.sum @@ -137,6 +137,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -165,8 +167,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/modules/dispatcher.go b/internal/modules/dispatcher.go index 373af35..4416a69 100644 --- a/internal/modules/dispatcher.go +++ b/internal/modules/dispatcher.go @@ -2,6 +2,7 @@ package modules import ( "context" + "runtime/debug" "strings" "time" @@ -71,6 +72,7 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) { return matchCommand(nameCopy, update) }, func(ctx context.Context, b *bot.Bot, update *models.Update) { + defer recoverHandler("command", cmdCopy.Name, nil) if !auth.Permits(cmdCopy.Visibility, update) { return // silent — do not leak existence of gated commands } @@ -78,6 +80,10 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) { // context.Background is intentional: the hook must outlive the request // context so stats writes complete even after the handler returns. go func() { //nolint:gosec // G118: goroutine intentionally detached from request context + // This goroutine is outside the handler's barrier above, so + // it needs its own: a panicking hook on its own goroutine + // still terminates the process. + defer recoverHandler("command hook", cmdCopy.Name, nil) hookCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() reg.RunCommandHooks(hookCtx, cmdCopy.Name, update) @@ -95,6 +101,11 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) { prefixCopy := prefix b.RegisterHandler(bot.HandlerTypeCallbackQueryData, prefixCopy, bot.MatchTypePrefix, func(ctx context.Context, b *bot.Bot, update *models.Update) { + defer recoverHandler("callback", prefixCopy, func() { + if update != nil && update.CallbackQuery != nil { + _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + } + }) if !auth.Permits(callbackCopy.Visibility, update) { if update != nil && update.CallbackQuery != nil { _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) @@ -110,6 +121,34 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) { } } +// recoverHandler contains a panic raised by a module handler. The bot runs with +// bot.WithNotAsyncHandlers() and a single worker, so the handler executes inline +// on the polling goroutine: without this barrier one panicking handler ends the +// process for every user. Mirrors the barrier the cron scheduler already puts +// around its handlers (internal/cron/scheduler.go). +// +// The panic is logged at ERROR with a full stack and counted under a distinct +// handler-panic metric, so a handler that panics on every call is loud rather +// than quietly failing per-request. +// +// onPanic, when non-nil, runs after logging — the callback path uses it to +// answer the query so the caller's client stops showing a spinner. It is itself +// guarded, because a panic raised inside the recovery path would have no +// remaining barrier. +func recoverHandler(kind, name string, onPanic func()) { + rec := recover() + if rec == nil { + return + } + metrics.IncError("handler-panic") + log.Error(kind+" panic", kind, name, "panic", rec, "stack", string(debug.Stack())) + if onPanic == nil { + return + } + defer func() { _ = recover() }() + onPanic() +} + // logCommand emits one structured line per authorized command invocation: what // was typed (input), who sent it (user id + @username), where (DM vs group, with // chat id and — for groups — the title), and the outcome. The result is kept @@ -163,8 +202,9 @@ func matchCommand(name string, update *models.Update) bool { continue } // Bounds check: defensive against malformed entities from a future - // API revision; the library's match func omits this so a bad entity - // would panic the goroutine before our recover() in webhook.go. + // API revision. The library's match func omits it, and a matcher runs + // outside Install's panic barrier, so a bad entity would panic the + // polling goroutine with nothing to catch it. end := e.Offset + e.Length if e.Offset < 0 || end > len(text) || e.Length < 1 { continue diff --git a/internal/modules/dispatcher_panic_test.go b/internal/modules/dispatcher_panic_test.go new file mode 100644 index 0000000..14980cc --- /dev/null +++ b/internal/modules/dispatcher_panic_test.go @@ -0,0 +1,203 @@ +package modules_test + +import ( + "bytes" + "context" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/metrics" + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +// The bot dispatches updates inline on a single polling goroutine +// (bot.WithNotAsyncHandlers), so an unrecovered handler panic takes the whole +// process down for every user — not just the caller who triggered it. These +// tests pin the barrier that stops that: the panic is contained, logged with a +// stack, and counted, and the process (here, the test binary) survives. + +// syncBuffer is a log sink safe to read while another goroutine writes. +// +// slog is concurrency-safe, but the *sink* it writes into is the test's +// responsibility — and one of these tests deliberately provokes a panic on a +// detached goroutine, which then logs while the test reads. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// captureLogs redirects the default logger for the duration of fn and returns +// everything written. Mirrors TestLogCommand's idiom. +func captureLogs(t *testing.T, fn func()) string { + t.Helper() + buf := &syncBuffer{} + prev := log.Default() + log.SetDefault(slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer log.SetDefault(prev) + fn() + return buf.String() +} + +// waitForLog polls buf until it contains needle, so a test never races a +// detached goroutine with a fixed sleep. +func waitForLog(t *testing.T, buf *syncBuffer, needle string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(buf.String(), needle) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for %q; log was %q", needle, buf.String()) +} + +// installPanicking builds a registry with one command and one callback that +// both panic, and installs it on a recording bot. +func installPanicking(t *testing.T) *testutil.RecordingBot { + t.Helper() + rb := testutil.NewRecordingBot(t) + reg, err := modules.Build([]string{"boom"}, map[string]modules.Factory{ + "boom": func(modules.Deps) modules.Module { + return modules.Module{ + Commands: []modules.Command{{ + Name: "boom", + Visibility: modules.VisibilityPublic, + Description: "panics on purpose", + Handler: func(context.Context, *bot.Bot, *models.Update) error { + panic("command exploded") + }, + }}, + Callbacks: []modules.Callback{{ + Prefix: "boom:", + Visibility: modules.VisibilityPublic, + Handler: func(context.Context, *bot.Bot, *models.Update) error { + panic("callback exploded") + }, + }}, + } + }, + }, storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("build registry: %v", err) + } + modules.Install(rb.Bot, reg, modules.Auth{}) + return rb +} + +func TestInstall_CommandPanicIsContained(t *testing.T) { + rb := installPanicking(t) + + // The assertion is that this line returns at all: without the barrier the + // panic unwinds through ProcessUpdate and kills the test binary. + logs := captureLogs(t, func() { + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/boom")) + metrics.Flush() + }) + + if !strings.Contains(logs, "command panic") { + t.Errorf("panic not logged; got %q", logs) + } + if !strings.Contains(logs, "command exploded") { + t.Errorf("panic value not logged; got %q", logs) + } + if !strings.Contains(logs, "stack") { + t.Errorf("stack not logged; got %q", logs) + } + // metrics.Flush renders the error counters into its log line, which is the + // only view of them from outside the metrics package. + if !strings.Contains(logs, "handler-panic") { + t.Errorf("handler-panic metric not incremented; got %q", logs) + } +} + +func TestInstall_CallbackPanicIsContainedAndAnswered(t *testing.T) { + rb := installPanicking(t) + + update := &models.Update{CallbackQuery: &models.CallbackQuery{ + ID: "cbq-1", + From: models.User{ID: 42}, + Data: "boom:go", + }} + + logs := captureLogs(t, func() { + rb.Bot.ProcessUpdate(context.Background(), update) + }) + + if !strings.Contains(logs, "callback panic") { + t.Errorf("panic not logged; got %q", logs) + } + + // A panicking callback must still answer the query, or the caller's client + // spins until it times out. + var answered bool + for _, call := range rb.Sent() { + if call.Method == "answerCallbackQuery" && call.Form["callback_query_id"] == "cbq-1" { + answered = true + } + } + if !answered { + t.Errorf("callback query not answered after panic; sent %+v", rb.Sent()) + } +} + +// The stats module registers a CommandHook, which runs on a detached goroutine +// outside the handler's barrier. A panic there has nothing above it on that +// goroutine's stack, so it terminates the process regardless of how well the +// handler itself is protected. +func TestInstall_CommandHookPanicIsContained(t *testing.T) { + rb := testutil.NewRecordingBot(t) + reg, err := modules.Build([]string{"hooky"}, map[string]modules.Factory{ + "hooky": func(modules.Deps) modules.Module { + return modules.Module{ + Commands: []modules.Command{{ + Name: "hooky", + Visibility: modules.VisibilityPublic, + Description: "fine itself; its hook is not", + Handler: func(context.Context, *bot.Bot, *models.Update) error { + return nil + }, + }}, + CommandHook: func(context.Context, string, *models.Update) { + panic("hook exploded") + }, + } + }, + }, storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("build registry: %v", err) + } + modules.Install(rb.Bot, reg, modules.Auth{}) + + buf := &syncBuffer{} + prev := log.Default() + log.SetDefault(slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer log.SetDefault(prev) + + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/hooky")) + + // The assertion is that the test binary is still alive to run it: without + // the barrier, the hook's panic has nothing above it on its own goroutine. + waitForLog(t, buf, "command hook panic") +} diff --git a/internal/modules/sticker/delpack_callback.go b/internal/modules/sticker/delpack_callback.go new file mode 100644 index 0000000..10756de --- /dev/null +++ b/internal/modules/sticker/delpack_callback.go @@ -0,0 +1,270 @@ +package sticker + +import ( + "context" + "errors" + "fmt" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/storage" +) + +// handleDelPack asks for confirmation before destroying the caller's pack. +// +// /delpack is also the *only* way to change a pack's URL, since Telegram has no +// rename-short-name method. That makes this prompt the last point at which a +// user who came here wanting a new link learns that the stickers do not survive +// the change — so it states the title, the count being lost, the exact link +// being surrendered, and that both are permanent. +func (s *state) handleDelPack(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + pack, found, err := getPack(ctx, s.store, ownerID) + if err != nil { + log.Error("sticker_delpack_load", "err", err) + return reply(ctx, b, msg, genericFailure) + } + if !found { + return reply(ctx, b, msg, noPackYet) + } + + // A pending record is bookkeeping, not proof that this bot created a set + // under that name on this user's behalf — /newpack writes it before + // Telegram is called, and anyone can make one naming any set. Deleting by + // set name is authorised by Telegram for every set this bot created, so + // confirming a delete from a pending record would let one user destroy + // another's pack. Clear the local record only, and touch nothing upstream. + if pack.Pending { + defer s.lockUser(ownerID)() + s.dropPackRecord(ctx, ownerID) + return reply(ctx, b, msg, fmt.Sprintf( + "Cleared an unfinished attempt at %s and freed the name. Nothing was deleted at Telegram; if that attempt did create a pack, it is no longer reachable through this bot.", + pack.Slug)) + } + + id, err := newActionID() + if err != nil { + log.Error("sticker_delpack_id", "err", err) + return reply(ctx, b, msg, genericFailure) + } + + now := s.now() + action := PendingDelete{ + ID: id, + OwnerID: ownerID, + Slug: pack.Slug, + SetName: pack.Name, + ChatID: msg.Chat.ID, + CreatedAt: now.UnixMilli(), + ExpiresAt: now.Add(pendingDeleteTTL).UnixMilli(), + } + + sent, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: msg.Chat.ID, + ReplyParameters: &models.ReplyParameters{MessageID: msg.ID}, + Text: fmt.Sprintf( + "Delete %s (%s)?\n\nThis destroys %d sticker(s) and gives up %s permanently. Neither can be recovered, and the link may not be reusable.", + pack.Title, pack.Slug, pack.Count, shareLink(pack.Name)), + ReplyMarkup: &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{{ + {Text: "Delete permanently", CallbackData: deleteCallbackData(id)}, + }}, + }, + }) + if err != nil { + log.Error("sticker_delpack_prompt", "err", err) + return err + } + + // Bind the action to the message carrying the button, so a press from a + // forwarded or replayed copy resolves to nothing. + action.MessageID = sent.ID + commitCtx, cancelCommit := commitContext(ctx) + defer cancelCommit() + if err := s.pending.Put(commitCtx, pendingDeleteKey(ownerID), action); err != nil { + log.Error("sticker_delpack_store", "err", err) + return reply(ctx, b, msg, genericFailure) + } + return nil +} + +// handleDelPackCallback consumes a confirm press. +func (s *state) handleDelPackCallback(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + if update == nil || update.CallbackQuery == nil { + return nil + } + query := update.CallbackQuery + + id, ok := parseDeleteCallback(query.Data) + if !ok { + return answerCallback(ctx, b, query.ID, "This confirmation is invalid.") + } + + // The lookup is keyed by the presser, never by the payload: the payload is + // client-controlled, so using it to choose *whose* action to load would let + // anyone address someone else's confirmation. + if query.From.ID == 0 { + return answerCallback(ctx, b, query.ID, "This confirmation is invalid.") + } + key := pendingDeleteKey(query.From.ID) + action, _, err := s.pending.Get(ctx, key) + if errors.Is(err, storage.ErrNotFound) { + return answerCallback(ctx, b, query.ID, "This confirmation expired or was already used.") + } + if err != nil { + log.Error("sticker_delpack_action_load", "err", err) + return answerCallback(ctx, b, query.ID, "Could not load this confirmation. Try /delpack again.") + } + + // Unreachable by construction — the action was loaded under this presser's + // own key, so a foreign press already returned above. Kept as defence in + // depth against a future change to how actions are addressed. + if query.From.ID != action.OwnerID { + return answerCallback(ctx, b, query.ID, "Only the user who ran /delpack can confirm it.") + } + + // CallbackQuery.Message is a MaybeInaccessibleMessage: nil for messages + // Telegram considers inaccessible. The panic barrier is the backstop, not a + // reason to skip the guard — and this must come before any use of msg. + msg := query.Message.Message + if msg == nil { + return answerCallback(ctx, b, query.ID, "This confirmation is no longer valid here.") + } + + // Binding first, and with no side effect. This press may be on somebody + // else's prompt: anyone in a group can tap anyone's button, so touching the + // message before proving it is the one this action was written for let a + // bystander strip the button off a live confirmation they had no part in. + // + // It also subsumes the stale-prompt case — an older prompt is a different + // message id, so it fails here. + if msg.Chat.ID != action.ChatID || msg.ID != action.MessageID || action.MessageID == 0 { + return answerCallback(ctx, b, query.ID, "This confirmation is no longer valid here.") + } + + // Defence in depth: the binding above already implies this, since a newer + // /delpack writes a new message id. Clearing is safe here only because the + // binding proved this is the caller's own bound message. + if action.ID != id { + clearButton(ctx, b, msg.Chat.ID, msg.ID) + return answerCallback(ctx, b, query.ID, "This confirmation was replaced by a newer /delpack.") + } + + if action.ExpiresAt <= s.now().UnixMilli() { + s.dropPendingDelete(ctx, key) + clearButton(ctx, b, action.ChatID, action.MessageID) + return answerCallback(ctx, b, query.ID, "This confirmation expired. Run /delpack again.") + } + + defer s.lockUser(action.OwnerID)() + + // Re-establish, under the lock, that the caller still holds this exact set. + // + // The authority to delete comes from the record, not from the prompt, and a + // prompt outlives the record: /delpack can sit unpressed for ten minutes + // while the pack disappears from Telegram's side, a self-heal frees the + // name, and somebody else claims it. DeleteStickerSet is keyed by set name + // and Telegram authorises it for every set this bot created, so a press + // then lands on whoever holds the name at that moment. + // + // Stated as an allowlist deliberately. The first version of this guard + // listed the states it would refuse — a pending record still naming this + // set — and fell through on the two that mattered: no record at all, and a + // record that had moved on to a different pack. Proving authority is the + // only formulation that fails closed against a state nobody thought of. + current, found, err := getPack(ctx, s.store, action.OwnerID) + if err != nil { + log.Error("sticker_delpack_recheck", "err", err) + return answerCallback(ctx, b, query.ID, "Could not confirm right now. Try /delpack again.") + } + // !found is stated explicitly even though ownsSet already returns false for + // a zero-value record's empty Name — mutation testing shows it is currently + // redundant. It stays because that redundancy is an accident of ownsSet's + // empty-string guard, not something this check should depend on. + if !found || current.Pending || !ownsSet(current, action.SetName) { + s.dropPendingDelete(ctx, key) + clearButton(ctx, b, action.ChatID, action.MessageID) + return answerCallback(ctx, b, query.ID, + "This confirmation is out of date — that pack is no longer yours to delete. Run /delpack again if you still want to.") + } + + // Consume the action *before* the destructive call, so a double press + // cannot delete twice or race a second confirmation. + if err := s.pending.Delete(ctx, key); err != nil { + if errors.Is(err, storage.ErrNotFound) { + return answerCallback(ctx, b, query.ID, "This confirmation was already used.") + } + log.Error("sticker_delpack_consume", "err", err) + return answerCallback(ctx, b, query.ID, "Could not confirm right now. Try /delpack again.") + } + + _, err = b.DeleteStickerSet(ctx, &bot.DeleteStickerSetParams{Name: action.SetName}) + switch { + case err == nil, isStickerSetMissing(err): + // Missing counts as success: the set is gone either way, and clearing + // the record is what unblocks /newpack. + // + // But clear it only if it still names *this* set. dropPackRecord deletes + // by owner, and the user's record may have moved on to a different pack + // since this confirmation was written — that is exactly what the + // documented /delpack-then-/newpack URL-change route does. Deleting + // blindly by owner would then erase a live pack's record. + s.dropPackRecordIfSet(ctx, action.OwnerID, action.SetName) + clearButton(ctx, b, action.ChatID, action.MessageID) + // Reply to the prompt rather than sending bare to the chat: in a forum + // supergroup a bare ChatID send lands in General instead of the topic + // the button lives in, leaking the pack name across topics and losing + // the confirmation. chathelper.Reply carries MessageThreadID. + _ = reply(ctx, b, msg, fmt.Sprintf("Deleted %s. You can create a new pack with /newpack.", action.Slug)) + return answerCallback(ctx, b, query.ID, "Pack deleted.") + + default: + // The record stays: we have no positive signal that the set is gone. + log.Error("sticker_delpack_delete", "err", err) + clearButton(ctx, b, action.ChatID, action.MessageID) + return answerCallback(ctx, b, query.ID, "Telegram refused the delete. Your pack is unchanged.") + } +} + +func (s *state) dropPendingDelete(ctx context.Context, key string) { + commitCtx, cancel := commitContext(ctx) + defer cancel() + if err := s.pending.Delete(commitCtx, key); err != nil && !errors.Is(err, storage.ErrNotFound) { + log.Error("sticker_drop_pending_delete", "err", err) + } +} + +func answerCallback(ctx context.Context, b *bot.Bot, queryID, text string) error { + _, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: queryID, + Text: text, + ShowAlert: true, + }) + return err +} + +// clearButton removes the inline keyboard so a spent prompt cannot be pressed +// again. Best effort: the action is already consumed either way. +func clearButton(ctx context.Context, b *bot.Bot, chatID int64, messageID int) { + _, err := b.EditMessageReplyMarkup(ctx, &bot.EditMessageReplyMarkupParams{ + ChatID: chatID, + MessageID: messageID, + ReplyMarkup: &models.InlineKeyboardMarkup{}, + }) + if err != nil { + log.Error("sticker_clear_button", "err", err) + } +} diff --git a/internal/modules/sticker/delpack_callback_test.go b/internal/modules/sticker/delpack_callback_test.go new file mode 100644 index 0000000..6397941 --- /dev/null +++ b/internal/modules/sticker/delpack_callback_test.go @@ -0,0 +1,489 @@ +package sticker + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +const promptMessageID = 555 + +// seedPendingDelete stores a confirm action as /delpack would have. +func seedPendingDelete(t *testing.T, s *state, mutate func(*PendingDelete)) PendingDelete { + t.Helper() + action := PendingDelete{ + ID: "abc123", + OwnerID: testUser, + Slug: "mypack", + SetName: testSet, + ChatID: testChat, + MessageID: promptMessageID, + CreatedAt: fixedNow.UnixMilli(), + ExpiresAt: fixedNow.Add(pendingDeleteTTL).UnixMilli(), + } + if mutate != nil { + mutate(&action) + } + if err := s.pending.Put(context.Background(), pendingDeleteKey(action.OwnerID), action); err != nil { + t.Fatalf("seed pending delete: %v", err) + } + return action +} + +// confirmPress builds the callback update for pressing the confirm button. +func confirmPress(action PendingDelete, presser int64) *models.Update { + return &models.Update{CallbackQuery: &models.CallbackQuery{ + ID: "cbq-1", + From: models.User{ID: presser}, + Data: deleteCallbackData(action.ID), + Message: models.MaybeInaccessibleMessage{ + Message: &models.Message{ + ID: action.MessageID, + Chat: models.Chat{ID: action.ChatID}, + }, + }, + }} +} + +// The prompt is the last point at which a user changing their pack's URL learns +// the stickers do not survive it, so it must state all four consequences. +func TestDelPack_PromptStatesConsequences(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 47) + + if err := s.handleDelPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil { + t.Fatalf("handleDelPack: %v", err) + } + + text := rb.LastSent().Text() + for _, want := range []string{"My Pack", "47", shareLink(testSet), "permanent"} { + if !strings.Contains(text, want) { + t.Errorf("confirm prompt %q missing %q", text, want) + } + } + if countMethod(rb, "deleteStickerSet") != 0 { + t.Error("/delpack deleted without confirmation") + } +} + +func TestDelPack_CallbackDataFitsTelegramLimit(t *testing.T) { + id, err := newActionID() + if err != nil { + t.Fatalf("newActionID: %v", err) + } + data := deleteCallbackData(id) + if len(data) > maxCallbackBytes { + t.Errorf("callback data is %d bytes, over the %d-byte limit", len(data), maxCallbackBytes) + } + got, ok := parseDeleteCallback(data) + if !ok || got != id { + t.Errorf("parseDeleteCallback(%q) = (%q, %v), want (%q, true)", data, got, ok, id) + } +} + +func TestDelPackCallback_HappyPath(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + action := seedPendingDelete(t, s, nil) + + if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + if countMethod(rb, "deleteStickerSet") != 1 { + t.Fatalf("methods = %v, want one deleteStickerSet", methodsSent(rb)) + } + if _, found := loadPack(t, s); found { + t.Error("pack record survived a confirmed delete") + } +} + +// Identity comes from From.ID, never from the payload — the payload is +// client-controlled. +// A foreign presser gets nothing. The mechanism is the key, not the owner +// comparison: the action is loaded by the presser's own id, so someone else's +// press finds no action at all and returns before the owner check is reached. +// +// Named for that, because the previous name claimed to exercise the owner +// comparison at delpack_callback.go and did not — that branch is unreachable +// by construction, and is kept only as defence in depth. +func TestDelPackCallback_ForeignPresserResolvesToNothing(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + action := seedPendingDelete(t, s, nil) + + if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser+1)); err != nil { + t.Fatalf("callback: %v", err) + } + if countMethod(rb, "deleteStickerSet") != 0 { + t.Errorf("methods = %v, want no delete", methodsSent(rb)) + } + if _, found := loadPack(t, s); !found { + t.Error("another user's press deleted the pack record") + } +} + +func TestDelPackCallback_RejectsExpired(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + action := seedPendingDelete(t, s, func(a *PendingDelete) { + a.ExpiresAt = fixedNow.Add(-time.Second).UnixMilli() + }) + + if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + if countMethod(rb, "deleteStickerSet") != 0 { + t.Errorf("methods = %v, want no delete after expiry", methodsSent(rb)) + } + if _, found := loadPack(t, s); !found { + t.Error("an expired press deleted the pack record") + } +} + +// The action is bound to the message carrying the button, so a press arriving +// from anywhere else resolves to nothing. +func TestDelPackCallback_RejectsWrongBinding(t *testing.T) { + cases := map[string]func(*models.Update){ + "different chat": func(u *models.Update) { u.CallbackQuery.Message.Message.Chat.ID = testChat + 1 }, + "different message": func(u *models.Update) { u.CallbackQuery.Message.Message.ID = promptMessageID + 1 }, + // MaybeInaccessibleMessage is nil for messages Telegram marks + // inaccessible; the panic barrier is a backstop, not a substitute. + "inaccessible message": func(u *models.Update) { u.CallbackQuery.Message.Message = nil }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + action := seedPendingDelete(t, s, nil) + + upd := confirmPress(action, testUser) + mutate(upd) + + if err := s.handleDelPackCallback(context.Background(), rb.Bot, upd); err != nil { + t.Fatalf("callback: %v", err) + } + if countMethod(rb, "deleteStickerSet") != 0 { + t.Errorf("methods = %v, want no delete", methodsSent(rb)) + } + if _, found := loadPack(t, s); !found { + t.Error("pack record deleted despite a broken binding") + } + }) + } +} + +// Single use: the action is consumed before the destructive call, so a second +// press finds nothing. +func TestDelPackCallback_SecondPressIsInert(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + action := seedPendingDelete(t, s, nil) + + press := func() { + if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + } + press() + press() + + if got := countMethod(rb, "deleteStickerSet"); got != 1 { + t.Errorf("deleteStickerSet calls = %d, want exactly 1", got) + } +} + +// A set Telegram already lost still clears the record — that is what unblocks +// /newpack after the phantom-record failure mode. +func TestDelPackCallback_MissingSetStillClearsRecord(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethodCode("deleteStickerSet", 400, "Bad Request: STICKERSET_INVALID") + s := newTestState() + seedPack(t, s, 3) + action := seedPendingDelete(t, s, nil) + + if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + if _, found := loadPack(t, s); found { + t.Error("record survived; /newpack stays blocked") + } +} + +// The mirror image: an unclassifiable failure leaves the pack alone. +func TestDelPackCallback_TransientErrorKeepsRecord(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethod("deleteStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) + s := newTestState() + seedPack(t, s, 3) + action := seedPendingDelete(t, s, nil) + + if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + if _, found := loadPack(t, s); !found { + t.Error("a transient delete failure destroyed the pack record") + } +} + +func TestDelPackCallback_IgnoresNonCallbackUpdate(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + if err := s.handleDelPackCallback(context.Background(), rb.Bot, &models.Update{}); err != nil { + t.Fatalf("callback with no query: %v", err) + } + if len(rb.Sent()) != 0 { + t.Errorf("methods = %v, want none", methodsSent(rb)) + } +} + +// The stale-confirmation regression. /delpack is the documented route to a new +// pack URL (delete, then /newpack under a new name), so a user genuinely can +// have an old prompt in scrollback while holding a *different*, live pack. +// +// Pressing the stale button deleted the old set, got STICKERSET_INVALID, and +// then cleared the record by owner id — erasing the record of the new, live +// pack and orphaning it permanently. +func TestDelPackCallback_StalePressLeavesTheCurrentPackAlone(t *testing.T) { + rb := testutil.NewRecordingBot(t) + // Registered so that if the guard ever lets the call through, it is the + // assertion below that reports it rather than a confusing downstream error. + rb.FailMethodCode("deleteStickerSet", 400, "Bad Request: STICKERSET_INVALID") + s := newTestState() + ctx := context.Background() + + // A confirmation written for the *old* pack. + stale := seedPendingDelete(t, s, func(a *PendingDelete) { + a.Slug = "oldslug" + a.SetName = "oldslug_by_testbot" + }) + + // The user has since created a new pack. + current := Pack{Slug: "newslug", Name: "newslug_by_testbot", Title: "New", OwnerID: testUser, Count: 7} + if err := s.store.Put(ctx, packKey(testUser), current); err != nil { + t.Fatalf("seed current pack: %v", err) + } + + if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(stale, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + + // Nothing may reach Telegram. The old name may since have been claimed by + // another user, and DeleteStickerSet is keyed by name alone — asserting only + // that this user's own record survived misses the cross-user damage + // entirely, which is how this went unnoticed. + if n := countMethod(rb, "deleteStickerSet"); n != 0 { + t.Errorf("deleteStickerSet calls = %d, want 0 — a stale confirmation reached Telegram", n) + } + + pack, found := loadPack(t, s) + if !found { + t.Fatal("the live pack's record was deleted by a stale confirmation") + } + if pack.Name != current.Name || pack.Count != 7 { + t.Errorf("pack = %+v, want the live newslug record untouched", pack) + } +} + +// Two /delpack runs must not leave two live capabilities. The second prompt +// supersedes the first, and pressing the first afterwards does nothing. +func TestDelPack_SecondPromptSupersedesTheFirst(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + ctx := context.Background() + + run := func() PendingDelete { + if err := s.handleDelPack(ctx, rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil { + t.Fatalf("handleDelPack: %v", err) + } + action, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)) + if err != nil { + t.Fatalf("load action: %v", err) + } + return action + } + first := run() + second := run() + + if first.ID == second.ID { + t.Fatal("both prompts share an id; the test cannot distinguish them") + } + // Exactly one action is stored, not two. + keys, err := s.pending.List(ctx, pendingDeletePrefix) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(keys) != 1 { + t.Errorf("stored pending actions = %d, want 1 — a public command must not accumulate documents", len(keys)) + } + + // The superseded button is inert. + if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(first, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + if countMethod(rb, "deleteStickerSet") != 0 { + t.Errorf("methods = %v, want the stale button to delete nothing", methodsSent(rb)) + } + if _, found := loadPack(t, s); !found { + t.Error("the superseded button deleted the pack") + } +} + +// Anyone in a group can tap anyone's inline button. Checking supersession +// before the chat/message binding meant a bystander's press stripped the button +// off a live confirmation they had no part in — no data leak, but the victim's +// prompt was destroyed and they had to start over. +func TestDelPackCallback_BystanderCannotTouchAnotherUsersPrompt(t *testing.T) { + const bystander = int64(2) + + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + ctx := context.Background() + + victimAction := seedPendingDelete(t, s, nil) + + // The bystander has their own pending confirmation, bound to their own + // message — this is what made action.ID differ and triggered the clear. + bystanderAction := PendingDelete{ + ID: "bbbb2222", OwnerID: bystander, Slug: "theirs", SetName: "theirs_by_testbot", + ChatID: testChat, MessageID: 777, + CreatedAt: fixedNow.UnixMilli(), ExpiresAt: fixedNow.Add(pendingDeleteTTL).UnixMilli(), + } + if err := s.pending.Put(ctx, pendingDeleteKey(bystander), bystanderAction); err != nil { + t.Fatalf("seed bystander action: %v", err) + } + + // The bystander presses the victim's button. + if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(victimAction, bystander)); err != nil { + t.Fatalf("callback: %v", err) + } + + if countMethod(rb, "editMessageReplyMarkup") != 0 { + t.Errorf("methods = %v; a bystander cleared the button on someone else's prompt", methodsSent(rb)) + } + if countMethod(rb, "deleteStickerSet") != 0 { + t.Errorf("methods = %v, want no delete", methodsSent(rb)) + } + // The victim's confirmation is untouched and still usable. + if _, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)); err != nil { + t.Errorf("victim's pending action was consumed by a bystander's press: %v", err) + } +} + +// A deleted pack must give its name back. Holding it forever would shrink the +// global namespace permanently and let a /newpack + /delpack loop burn one name +// per cycle. +func TestDelPackCallback_ReleasesTheName(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + ctx := context.Background() + action := seedPendingDelete(t, s, nil) + + if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); !held { + t.Fatal("fixture is wrong: the pack should start with its name reserved") + } + + if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(action, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { + t.Error("the name is still reserved after the pack was deleted") + } +} + +// A confirmation must never outlive the authority it was issued under. +// +// Each case leaves the PendingDelete intact and breaks the pack record a +// different way, so the press actually reaches the under-lock allowlist. An +// earlier version of this test called dropPackRecord, which now clears the +// confirmation too — so the callback returned at the pending.Get miss ~50 lines +// before the guard, and the test passed with the whole guard reverted. Every +// disjunct is exercised here on purpose. +// +// The damage is cross-user: DeleteStickerSet is keyed by set name, which +// Telegram authorises for every set this bot created, so a press with stale +// authority destroys whoever holds that name at press time. +func TestDelPackCallback_StaleAuthorityNeverReachesTelegram(t *testing.T) { + const victim = int64(99) + + cases := []struct { + name string + break_ func(t *testing.T, s *state, ctx context.Context) + }{ + { + // !found — a self-heal removed the record but the prompt survived. + name: "record gone", + break_: func(t *testing.T, s *state, ctx context.Context) { + if err := s.store.Delete(ctx, packKey(testUser)); err != nil { + t.Fatalf("delete record: %v", err) + } + }, + }, + { + // current.Pending — the record is an unconfirmed attempt, which is + // no evidence this bot made that set for this user. + name: "record is unconfirmed", + break_: func(t *testing.T, s *state, ctx context.Context) { + pack, _ := loadPack(t, s) + pack.Pending = true + if err := s.store.Put(ctx, packKey(testUser), pack); err != nil { + t.Fatalf("mark pending: %v", err) + } + }, + }, + { + // !ownsSet — the record has moved on to a different pack. + name: "record moved on", + break_: func(t *testing.T, s *state, ctx context.Context) { + if err := s.store.Put(ctx, packKey(testUser), Pack{ + Slug: "newslug", Name: "newslug_by_testbot", Title: "New", OwnerID: testUser, Count: 7, + }); err != nil { + t.Fatalf("move record: %v", err) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + ctx := context.Background() + + seedPack(t, s, 3) + action := seedPendingDelete(t, s, nil) + tc.break_(t, s, ctx) + + // Someone else now holds that name, with a live set behind it. + if err := s.store.Put(ctx, packKey(victim), Pack{ + Slug: "mypack", Name: testSet, Title: "V's pack", OwnerID: victim, Count: 5, + }); err != nil { + t.Fatalf("seed victim: %v", err) + } + + if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(action, testUser)); err != nil { + t.Fatalf("callback: %v", err) + } + + if n := countMethod(rb, "deleteStickerSet"); n != 0 { + t.Errorf("deleteStickerSet calls = %d, want 0 — a stale confirmation destroyed the current holder's pack", n) + } + if _, _, err := s.store.Get(ctx, packKey(victim)); err != nil { + t.Errorf("victim's pack record damaged: %v", err) + } + }) + } +} diff --git a/internal/modules/sticker/download.go b/internal/modules/sticker/download.go new file mode 100644 index 0000000..ae8ea68 --- /dev/null +++ b/internal/modules/sticker/download.go @@ -0,0 +1,106 @@ +package sticker + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-telegram/bot" + + "github.com/tiennm99/miti99bot/internal/log" +) + +const ( + // maxSourceBytes bounds what this module will pull over the network. + // Telegram-compressed photo sizes are typically well under 500 KB. + maxSourceBytes = 2 << 20 + + // downloadTimeout is this module's own ceiling. The library's shared HTTP + // client allows 60s, which is far too long to inherit on a dispatcher where + // one slow handler stalls every other user. + downloadTimeout = 8 * time.Second +) + +// errDownloadFailed replaces every error from the download path. +// +// This is a security boundary, not tidiness. FileDownloadLink returns +// "https://api.telegram.org/file/bot/", and every transport +// failure from http.Client.Do is a *url.Error whose Error() embeds the full +// URL — which the dispatcher then logs verbatim. A mid-transfer timeout, which +// is trivially reachable, would print the bot token to stdout and every log +// shipper downstream. +var errDownloadFailed = errors.New("sticker: download failed") + +// downloadClient is separate from the library's so its timeout is ours. +var downloadClient = &http.Client{Timeout: downloadTimeout} + +// downloadFile fetches a Telegram file by ID, bounded in bytes and time. +// +// The original error is discarded rather than wrapped: wrapping would keep the +// URL reachable through errors.Unwrap and %v, which defeats the point. +func downloadFile(ctx context.Context, b *bot.Bot, fileID string) ([]byte, error) { + f, err := b.GetFile(ctx, &bot.GetFileParams{FileID: fileID}) + if err != nil { + log.Error("sticker_getfile", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + if f.FileSize > maxSourceBytes { + return nil, refuse(fmt.Sprintf("That image is too large — keep it under %d MB.", maxSourceBytes>>20)) + } + + link := b.FileDownloadLink(f) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil) + if err != nil { + log.Error("sticker_download_request", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + resp, err := downloadClient.Do(req) + if err != nil { + log.Error("sticker_download", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + log.Error("sticker_download", "file_id", fileID, "reason", "status", "status", resp.StatusCode) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + + // Never trust Content-Length; bound the reader itself. One extra byte is + // read so an oversized body is detected rather than silently truncated. + data, err := io.ReadAll(io.LimitReader(resp.Body, maxSourceBytes+1)) + if err != nil { + log.Error("sticker_download_read", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + if len(data) > maxSourceBytes { + return nil, refuse(fmt.Sprintf("That image is too large — keep it under %d MB.", maxSourceBytes>>20)) + } + return data, nil +} + +// classify reduces an error to a coarse label that cannot contain a URL. +// +// It deliberately inspects only the error's *type*, never its text: any path +// that formats the original error risks carrying the token along with it. +func classify(err error) string { + var urlErr *url.Error + switch { + case err == nil: + return "none" + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case errors.Is(err, context.Canceled): + return "cancelled" + case errors.As(err, &urlErr): + if urlErr.Timeout() { + return "timeout" + } + return "transport" + } + return "unknown" +} diff --git a/internal/modules/sticker/download_test.go b/internal/modules/sticker/download_test.go new file mode 100644 index 0000000..9bb7db2 --- /dev/null +++ b/internal/modules/sticker/download_test.go @@ -0,0 +1,157 @@ +package sticker + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-telegram/bot" +) + +// The download URL is "https://api.telegram.org/file/bot/", and +// every transport failure from http.Client.Do is a *url.Error whose Error() +// embeds it in full. The dispatcher logs a handler's returned error verbatim, +// so an error that carried the URL would print the bot token to stdout and +// every log shipper downstream. +// +// This asserts the property directly rather than trusting the discipline: a +// forced transport failure must produce an error that contains neither the +// token, nor "bot", nor any part of the URL. +func TestDownloadFile_ErrorNeverLeaksTokenOrURL(t *testing.T) { + const token = "123456:SUPER-SECRET-BOT-TOKEN" + + // A server that accepts getFile, then hangs up mid-download. + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/getFile") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/file_1.jpg"}}`)) + return + } + // The file fetch: close the connection without a response. + hj, ok := w.(http.Hijacker) + if !ok { + srv.CloseClientConnections() + return + } + conn, _, err := hj.Hijack() + if err == nil { + _ = conn.Close() + } + })) + defer srv.Close() + + b, err := bot.New(token, bot.WithSkipGetMe(), bot.WithServerURL(srv.URL)) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + + _, err = downloadFile(context.Background(), b, "f1") + if err == nil { + t.Fatal("downloadFile succeeded against a hung-up server; want an error") + } + if !errors.Is(err, errDownloadFailed) { + t.Errorf("err = %v, want it to be errDownloadFailed", err) + } + + text := err.Error() + for _, forbidden := range []string{token, "SUPER-SECRET", "bot", "http", srv.URL} { + if strings.Contains(text, forbidden) { + t.Errorf("error text %q contains %q — the token or URL can reach the logs", text, forbidden) + } + } + // Unwrapping must not reach the original either: %v on a wrapped *url.Error + // would put the URL back. + if inner := errors.Unwrap(err); inner != nil && strings.Contains(inner.Error(), token) { + t.Errorf("unwrapped error %q still carries the token", inner) + } +} + +// classify must reduce an error to a fixed label. It inspects only the error's +// type, never its text, so there is no path by which a URL can ride along. +func TestClassify_ReturnsFixedLabels(t *testing.T) { + allowed := map[string]bool{"none": true, "timeout": true, "cancelled": true, "transport": true, "unknown": true} + cases := []error{ + nil, + context.DeadlineExceeded, + context.Canceled, + errors.New("https://api.telegram.org/file/bot123:SECRET/x.jpg refused"), + } + for _, err := range cases { + got := classify(err) + if !allowed[got] { + t.Errorf("classify(%v) = %q, which is not one of the fixed labels", err, got) + } + if strings.Contains(got, "SECRET") || strings.Contains(got, "http") { + t.Errorf("classify leaked error text: %q", got) + } + } +} + +// The size guard runs on the metadata getFile returns, so an oversized file +// costs zero bytes of transfer. +func TestDownloadFile_RejectsOversizedBeforeFetching(t *testing.T) { + var fetches int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/getFile") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":99999999,"file_path":"photos/huge.jpg"}}`)) + return + } + fetches++ + _, _ = w.Write([]byte("should never be fetched")) + })) + defer srv.Close() + + b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL)) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + + if _, err := downloadFile(context.Background(), b, "f1"); err == nil { + t.Fatal("downloadFile accepted an oversized file") + } + if fetches != 0 { + t.Errorf("made %d HTTP fetches for an oversized file, want 0", fetches) + } +} + +// Content-Length is attacker-controlled; the reader itself is what bounds the +// transfer. +func TestDownloadFile_BoundsBodyRegardlessOfContentLength(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/getFile") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/lie.jpg"}}`)) + return + } + // Claims to be tiny, sends far more. + w.Header().Set("Content-Length", "10") + w.Header().Set("Content-Type", "image/jpeg") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + chunk := make([]byte, 64<<10) + for written := 0; written < maxSourceBytes+(128<<10); written += len(chunk) { + if _, err := w.Write(chunk); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + } + })) + defer srv.Close() + + b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL)) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + + data, err := downloadFile(context.Background(), b, "f1") + if err == nil { + t.Fatalf("downloadFile accepted %d bytes despite the %d-byte cap", len(data), maxSourceBytes) + } +} diff --git a/internal/modules/sticker/emoji.go b/internal/modules/sticker/emoji.go new file mode 100644 index 0000000..bdf00e1 --- /dev/null +++ b/internal/modules/sticker/emoji.go @@ -0,0 +1,209 @@ +package sticker + +import ( + "fmt" + "strings" + "unicode" +) + +const ( + // defaultEmoji is used when a sticker is added with no emoji given and the + // source carries none. Telegram requires at least one. + defaultEmoji = "⭐" + // maxEmojiPerSticker mirrors the documented emoji_list range of 1-20. + maxEmojiPerSticker = 20 +) + +// Code points that bind to the cluster before them rather than starting one. +// Written as hex because the literals are invisible in source. +const ( + zwj = rune(0x200D) // zero-width joiner: 👩‍👩‍👧 is one emoji + variationSelector16 = rune(0xFE0F) // forces emoji presentation + variationSelector15 = rune(0xFE0E) // forces text presentation + keycapCombining = rune(0x20E3) // the enclosing box of 1️⃣ + tagLow = rune(0xE0020) // tag block: 🏴 + tags spells a subdivision flag + tagHigh = rune(0xE007F) +) + +// parseEmoji splits an emoji argument run into individual emoji, accepting both +// "😂 🔥" and "😂🔥". +// +// Go has no stdlib grapheme segmentation, and a dependency for this one job is +// disproportionate — so this hand-rolls the subset of UAX #29 that emoji need: +// ZWJ sequences, variation selectors, skin-tone modifiers, regional-indicator +// pairs, and keycaps. A sequence that splits wrongly is a test case to add, not +// a redesign. +// +// Because no sticker command takes a pack argument any more, every argument +// reaching here is meant to be an emoji: a stray word fails loudly rather than +// being silently reinterpreted. +func parseEmoji(args []string) ([]string, error) { + joined := strings.Join(args, "") + joined = strings.TrimSpace(joined) + if joined == "" { + return nil, nil + } + + var out []string + for _, cluster := range splitClusters(joined) { + if isSpace(cluster) { + continue + } + if !isEmojiCluster(cluster) { + return nil, refuse(fmt.Sprintf("%q is not an emoji. Give one or more emoji, like 😂🔥.", cluster)) + } + out = append(out, cluster) + } + if len(out) > maxEmojiPerSticker { + return nil, refuse(fmt.Sprintf("At most %d emoji per sticker.", maxEmojiPerSticker)) + } + return out, nil +} + +// splitClusters breaks s into emoji-aware clusters. +func splitClusters(s string) []string { + runes := []rune(s) + var out []string + for i := 0; i < len(runes); { + start := i + i++ + // A regional indicator pairs with a following one to form a flag. + if isRegionalIndicator(runes[start]) && i < len(runes) && isRegionalIndicator(runes[i]) { + i++ + out = append(out, string(runes[start:i])) + continue + } + // Absorb everything that binds leftward: modifiers, variation + // selectors, combining marks, keycaps, and ZWJ-joined continuations. + for i < len(runes) { + r := runes[i] + switch { + case r == zwj: + // Absorb the joiner and its continuation only when what follows + // can actually continue an emoji sequence. Taking the next rune + // unconditionally let "😀🇻🇳" swallow the flag's first + // half and emit the orphaned second half as its own cluster — + // two invalid emoji, both of which passed validation. + if i+1 < len(runes) && isEmojiRune(runes[i+1]) && !isRegionalIndicator(runes[i+1]) { + i += 2 + continue + } + // A dangling joiner. Absorb it so it cannot start a cluster of + // its own; trimJoiners drops it from the emitted cluster. + i++ + goto done + case isBinding(r): + i++ + default: + goto done + } + } + done: + // A cluster ending in a joiner is incomplete. "😀" is not an emoji + // and Telegram rejects it, but it used to pass validation because the + // cluster's first rune looked fine. + if cluster := trimJoiners(string(runes[start:i])); cluster != "" { + out = append(out, cluster) + } + } + return out +} + +// trimJoiners strips leading and trailing zero-width joiners from a cluster. +func trimJoiners(cluster string) string { + return strings.Trim(cluster, string(zwj)) +} + +// isBinding reports whether r attaches to the preceding cluster. +func isBinding(r rune) bool { + switch { + case r == variationSelector16, r == variationSelector15, r == keycapCombining: + return true + case r >= tagLow && r <= tagHigh: + // Subdivision flags (🏴 + "gbeng" + terminator) are one emoji. Without + // this the base flag split from its tags and the tags, being invisible, + // produced a refusal quoting characters the user could not see. + return true + case isSkinTone(r): + return true + case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Me, r): + return true + } + return false +} + +func isSkinTone(r rune) bool { return r >= 0x1F3FB && r <= 0x1F3FF } +func isRegionalIndicator(r rune) bool { return r >= 0x1F1E6 && r <= 0x1F1FF } + +func isSpace(cluster string) bool { return strings.TrimSpace(cluster) == "" } + +// isEmojiCluster reports whether a cluster is emoji rather than ordinary text. +// +// The test is on the cluster's first rune, since that is what determines the +// cluster's identity — the rest is bound modifiers. Keycaps are the exception: +// "1️⃣" starts with an ASCII digit, so a cluster carrying the keycap combining +// mark counts regardless of its base. +func isEmojiCluster(cluster string) bool { + runes := []rune(cluster) + if len(runes) == 0 { + return false + } + if strings.ContainsRune(cluster, keycapCombining) { + return true + } + if isRegionalIndicator(runes[0]) { + // A flag is exactly two regional indicators. An odd count leaves a lone + // one at the end, which is not an emoji — Telegram rejects it, and it + // used to pass because isEmojiRune accepts the block. + return len(runes) == 2 && isRegionalIndicator(runes[1]) + } + return isEmojiRune(runes[0]) +} + +// emojiRanges are the Unicode blocks Telegram's emoji actually come from. +// Deliberately ranges rather than a property lookup: Go's unicode package +// exposes no Emoji property, and the blocks are stable. +var emojiRanges = [...]struct{ lo, hi rune }{ + {0x1F300, 0x1FAFF}, // pictographs, emoticons, transport, symbols, extended-A + {0x1F000, 0x1F2FF}, // mahjong, dominoes, playing cards, enclosed + {0x2600, 0x27BF}, // misc symbols + dingbats + {0x2B00, 0x2BFF}, // arrows and stars (⭐ lives here) + {0x2190, 0x21FF}, // arrows + {0x2300, 0x23FF}, // misc technical (⌚, ⏰) + {0x25A0, 0x25FF}, // geometric shapes + {0x1F1E6, 0x1F1FF}, // regional indicators; isEmojiCluster requires a pair +} + +// emojiSingletons are emoji stranded between the blocks above. +// +// Listed one by one rather than by widening a range, because their neighbours +// are not emoji: Ⓜ sits in enclosed alphanumerics next to circled digits, and © +// and ® sit in Latin-1 next to ordinary punctuation. Widening to cover them +// would start accepting text as emoji, which Telegram then rejects. +var emojiSingletons = map[rune]bool{ + 0x203C: true, // ‼ + 0x2049: true, // ⁉ + 0x2122: true, // ™ + 0x2139: true, // ℹ + 0x00A9: true, // © + 0x00AE: true, // ® + 0x2934: true, // ⤴ + 0x2935: true, // ⤵ + 0x24C2: true, // Ⓜ + 0x3030: true, // 〰 + 0x303D: true, // 〽 + 0x3297: true, // ㊗ + 0x3299: true, // ㊙ +} + +func isEmojiRune(r rune) bool { + if emojiSingletons[r] { + return true + } + for _, block := range emojiRanges { + if r >= block.lo && r <= block.hi { + return true + } + } + return false +} diff --git a/internal/modules/sticker/emoji_test.go b/internal/modules/sticker/emoji_test.go new file mode 100644 index 0000000..8d1b1b7 --- /dev/null +++ b/internal/modules/sticker/emoji_test.go @@ -0,0 +1,154 @@ +package sticker + +import ( + "strings" + "testing" +) + +func TestParseEmoji(t *testing.T) { + cases := []struct { + name string + args []string + want []string + }{ + {"single", []string{"😂"}, []string{"😂"}}, + {"space separated", []string{"😂", "🔥"}, []string{"😂", "🔥"}}, + {"joined in one arg", []string{"😂🔥"}, []string{"😂", "🔥"}}, + {"zwj family stays one", []string{"👩\u200d👩\u200d👧"}, []string{"👩\u200d👩\u200d👧"}}, + {"skin tone binds", []string{"👍🏽"}, []string{"👍🏽"}}, + {"flag is one cluster", []string{"🇻🇳"}, []string{"🇻🇳"}}, + {"two flags", []string{"🇻🇳🇯🇵"}, []string{"🇻🇳", "🇯🇵"}}, + {"keycap stays one", []string{"1️⃣"}, []string{"1️⃣"}}, + {"variation selector binds", []string{"❤️"}, []string{"❤️"}}, + {"star default", []string{"⭐"}, []string{"⭐"}}, + {"empty", nil, nil}, + {"blank", []string{" "}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseEmoji(tc.args) + if err != nil { + t.Fatalf("parseEmoji(%q) error: %v", tc.args, err) + } + if len(got) != len(tc.want) { + t.Fatalf("parseEmoji(%q) = %q, want %q", tc.args, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("cluster %d = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + +// A stray word must fail loudly. With no pack argument left on /addsticker, +// there is nothing else an argument could have meant. +func TestParseEmoji_RejectsPlainText(t *testing.T) { + for _, arg := range []string{"mypack", "hello 😂", "a"} { + if got, err := parseEmoji([]string{arg}); err == nil { + t.Errorf("parseEmoji(%q) = %q, want an error", arg, got) + } + } +} + +func TestParseEmoji_CapsAtTwenty(t *testing.T) { + twenty := strings.Repeat("😂", maxEmojiPerSticker) + if got, err := parseEmoji([]string{twenty}); err != nil { + t.Fatalf("parseEmoji(20 emoji) error: %v (got %d)", err, len(got)) + } + if _, err := parseEmoji([]string{twenty + "🔥"}); err == nil { + t.Error("parseEmoji(21 emoji) succeeded, want an error") + } +} + +// Refusals are shown to the user verbatim, so they must be userError. +func TestParseEmoji_RefusalIsUserFacing(t *testing.T) { + _, err := parseEmoji([]string{"notanemoji"}) + if err == nil { + t.Fatal("want an error") + } + if _, ok := err.(userError); !ok { + t.Errorf("err is %T, want userError so the handler can echo it", err) + } +} + +// TestParseEmoji_ClusterEdgeCases pins the emoji-clustering rules that a +// hand-rolled segmenter gets wrong. Every case here failed before the +// clustering fix: the first group was refused outright, the second silently +// produced an emoji_list Telegram rejects. +func TestParseEmoji_ClusterEdgeCases(t *testing.T) { + accepted := []struct { + name string + in string + want []string + }{ + // Emoji that fall between the blocks the range table covers. + {"copyright", "©️", []string{"©️"}}, + {"registered", "®️", []string{"®️"}}, + {"wavy dash", "〰️", []string{"〰️"}}, + {"part alternation", "〽️", []string{"〽️"}}, + {"japanese congratulations", "㊗️", []string{"㊗️"}}, + {"japanese secret", "㊙️", []string{"㊙️"}}, + {"circled m", "Ⓜ️", []string{"Ⓜ️"}}, + {"arrow curving up", "⤴️", []string{"⤴️"}}, + {"arrow curving down", "⤵️", []string{"⤵️"}}, + + // A subdivision flag is a base flag plus invisible tag characters. + { + "tag sequence flag", + "\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F", + []string{"\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F"}, + }, + + // A dangling joiner is dropped rather than shipped. + {"trailing joiner", "\U0001F600\u200d", []string{"\U0001F600"}}, + + // The joiner must not swallow a flag's first half. + { + "joiner before a flag", + "\U0001F600\u200d\U0001F1FB\U0001F1F3", + []string{"\U0001F600", "\U0001F1FB\U0001F1F3"}, + }, + + // Sequences that already worked, kept here so a fix cannot regress them. + {"family", "\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466", []string{"\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466"}}, + {"skin tone", "\U0001F44D\U0001F3FD", []string{"\U0001F44D\U0001F3FD"}}, + {"keycap", "1️⃣", []string{"1️⃣"}}, + {"flag", "\U0001F1FB\U0001F1F3", []string{"\U0001F1FB\U0001F1F3"}}, + } + for _, tc := range accepted { + t.Run(tc.name, func(t *testing.T) { + got, err := parseEmoji([]string{tc.in}) + if err != nil { + t.Fatalf("parseEmoji(%+q) refused: %v", tc.in, err) + } + if len(got) != len(tc.want) { + t.Fatalf("parseEmoji(%+q) = %+q, want %+q", tc.in, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("parseEmoji(%+q) = %+q, want %+q", tc.in, got, tc.want) + } + } + }) + } + + refused := []struct { + name string + in string + }{ + // A lone regional indicator is half a flag, and Telegram rejects it. + {"lone regional indicator", "\U0001F1FB"}, + {"odd regional indicator count", "\U0001F1FB\U0001F1F3\U0001F1FA"}, + {"plain text", "hello"}, + } + for _, tc := range refused { + t.Run(tc.name, func(t *testing.T) { + got, err := parseEmoji([]string{tc.in}) + if err == nil { + t.Fatalf("parseEmoji(%+q) = %+q, want refusal", tc.in, got) + } + }) + } +} diff --git a/internal/modules/sticker/errors.go b/internal/modules/sticker/errors.go new file mode 100644 index 0000000..ba3e510 --- /dev/null +++ b/internal/modules/sticker/errors.go @@ -0,0 +1,123 @@ +package sticker + +import ( + "context" + "errors" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" +) + +// userError carries text meant to be shown to the user verbatim. +// +// The module has two kinds of failure and must never confuse them: a refusal +// the user can act on ("that pack name is too long"), and an internal failure +// that must not reach a reply at all — plan rule 5 exists because a transport +// error's text can embed the bot token. Wrapping the first kind in a distinct +// type makes "is this safe to echo?" a type question instead of a judgement +// call at each call site. +type userError struct{ msg string } + +func (e userError) Error() string { return e.msg } + +// refuse builds a userError. Its text is replied verbatim, so write it as a +// sentence addressed to the user. +func refuse(msg string) error { return userError{msg: msg} } + +// errNoUsername means the bot's own username is unavailable, so no new set name +// can be built. Internal, not user-facing: nothing the caller does fixes it. +var errNoUsername = errors.New("sticker: bot has no username") + +// isStickerSetMissing reports whether err positively says the set does not +// exist on Telegram's side. +// +// Classification here is positive-only, and deliberately so: this is the one +// signal that authorises deleting a user's pack record. A network blip, a 429, +// or a context cancelled by SIGTERM must never be read as "the pack is gone" — +// under one pack per user that would destroy the only record of a live pack and +// block /newpack until the phantom cleared. +func isStickerSetMissing(err error) bool { + return errors.Is(err, bot.ErrorBadRequest) && + strings.Contains(err.Error(), "STICKERSET_INVALID") +} + +// apiRefusal maps a Telegram API error to user-facing text, or returns ok=false +// when the error has no specific meaning and should be treated as a failure. +// +// Matching is on MTProto code substrings rather than prose. The Bot API server +// rewrites only three of these into English (PACK_SHORT_NAME_OCCUPIED, +// PACK_SHORT_NAME_INVALID, STICKER_EMOJI_INVALID); the rest arrive as +// "Bad Request: ", and the prose for the three could change without +// notice. Both forms are matched where they differ. +func apiRefusal(err error) (string, bool) { + if err == nil { + return "", false + } + text := err.Error() + switch { + case contains(text, "PACK_SHORT_NAME_OCCUPIED", "already occupied"): + return "That pack name is taken. Pick a different one.", true + case contains(text, "PACK_SHORT_NAME_INVALID", "invalid sticker set name"): + return "Telegram rejected that pack name. Use lowercase letters, digits and single underscores.", true + case contains(text, "PACK_TITLE_INVALID"): + return "Telegram rejected that title. Try a shorter, simpler one.", true + case contains(text, "STICKERSET_INVALID"): + return "Your pack no longer exists on Telegram. Use /newpack to create a new one.", true + case contains(text, "STICKERS_TOO_MUCH"): + return "Your pack is full (120 stickers).", true + case contains(text, "STICKER_EMOJI_INVALID", "invalid sticker emojis"): + return "Telegram rejected those emoji. Try different ones.", true + case contains(text, "too many emoji specified"): + return "At most 20 emoji per sticker.", true + case contains(text, "STICKER_PNG_DIMENSIONS", "STICKER_DIMENSIONS_INVALID"): + return "Telegram rejected that image's dimensions.", true + } + return "", false +} + +// replyAPIError converts a Telegram API error into a reply. Errors with no +// specific mapping are logged and answered generically — the raw error never +// reaches the user. +func replyAPIError(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error { + if text, ok := apiRefusal(err); ok { + return reply(ctx, b, msg, text) + } + log.Error(op, "err", err) + return reply(ctx, b, msg, genericFailure) +} + +func contains(text string, needles ...string) bool { + for _, n := range needles { + if strings.Contains(text, n) { + return true + } + } + return false +} + +// createRefused reports whether err proves CreateNewStickerSet created nothing. +// +// Deliberately separate from apiRefusal even though today their code lists +// overlap. apiRefusal's job is "map an error to user-facing text"; this one's is +// "prove no set exists", which is what authorises releasing a name reservation +// and dropping a write-ahead intent. Reusing apiRefusal for both would mean the +// next person adding a code there for wording reasons silently converts it into +// a strand-the-slug bug. +// +// Every code here is a request-validation refusal: Telegram rejected the call +// before creating anything. +func createRefused(err error) bool { + if err == nil { + return false + } + return contains(err.Error(), + "PACK_SHORT_NAME_OCCUPIED", "already occupied", + "PACK_SHORT_NAME_INVALID", "invalid sticker set name", + "PACK_TITLE_INVALID", + "STICKER_EMOJI_INVALID", "invalid sticker emojis", + "too many emoji specified", + ) +} diff --git a/internal/modules/sticker/handlers_test.go b/internal/modules/sticker/handlers_test.go new file mode 100644 index 0000000..12ad720 --- /dev/null +++ b/internal/modules/sticker/handlers_test.go @@ -0,0 +1,440 @@ +package sticker + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +const ( + testUser = int64(42) + testChat = int64(1000) + testSet = "mypack_by_testbot" + otherSet = "someoneelse_by_testbot" +) + +var fixedNow = time.UnixMilli(1_700_000_000_000) + +// newTestState builds a state over one in-memory collection, matching +// production: both typed views share the collection, with disjoint key spaces. +func newTestState() *state { + coll := storage.NewMemoryProvider().Collection("sticker") + return &state{ + store: storage.Typed[Pack](coll), + pending: storage.Typed[PendingDelete](coll), + slugs: storage.Typed[SlugReservation](coll), + nowFn: func() time.Time { return fixedNow }, + } +} + +func seedPack(t *testing.T, s *state, count int) Pack { + t.Helper() + pack := Pack{ + Slug: "mypack", Name: testSet, Title: "My Pack", + OwnerID: testUser, Count: count, CreatedAt: fixedNow.UnixMilli(), + } + if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil { + t.Fatalf("seed pack: %v", err) + } + // A real pack always carries its name reservation; seeding without one + // would let tests pass against a state production cannot reach. + if err := s.slugs.Put(context.Background(), slugKey(pack.Slug), + SlugReservation{Slug: pack.Slug, OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed reservation: %v", err) + } + return pack +} + +// stickerReply builds a message replying to a sticker in setName. +func stickerReply(text, setName string) *models.Update { + upd := testutil.NewPrivateMessage(testUser, text) + upd.Message.Chat.ID = testChat + upd.Message.ReplyToMessage = &models.Message{ + Sticker: &models.Sticker{ + FileID: "file-in-" + setName, + FileUniqueID: "uniq", + Type: "regular", + SetName: setName, + Emoji: "🎉", + }, + } + return upd +} + +func loadPack(t *testing.T, s *state) (Pack, bool) { + t.Helper() + pack, found, err := getPack(context.Background(), s.store, testUser) + if err != nil { + t.Fatalf("load pack: %v", err) + } + return pack, found +} + +// methodsSent lists the API methods a run produced, so a test can assert both +// what was called and that nothing was. +func methodsSent(rb *testutil.RecordingBot) []string { + var out []string + for _, call := range rb.Sent() { + out = append(out, call.Method) + } + return out +} + +func countMethod(rb *testutil.RecordingBot, method string) int { + n := 0 + for _, call := range rb.Sent() { + if call.Method == method { + n++ + } + } + return n +} + +func TestAddSticker_HappyPath(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + + if countMethod(rb, "addStickerToSet") != 1 { + t.Fatalf("methods = %v, want one addStickerToSet", methodsSent(rb)) + } + for _, call := range rb.Sent() { + if call.Method != "addStickerToSet" { + continue + } + if call.Form["name"] != testSet { + t.Errorf("name = %q, want %q", call.Form["name"], testSet) + } + // UserID is always the caller: a non-owner never reaches this call. + if call.Form["user_id"] != "42" { + t.Errorf("user_id = %q, want 42", call.Form["user_id"]) + } + if !strings.Contains(call.Form["sticker"], "😂") { + t.Errorf("sticker payload %q missing the explicit emoji", call.Form["sticker"]) + } + } + + pack, _ := loadPack(t, s) + if pack.Count != 4 { + t.Errorf("Count = %d, want 4", pack.Count) + } +} + +// Explicit args beat the replied sticker's emoji, which beats the default. +// addedStickerPayload returns the payload of the one addStickerToSet call the +// handler is expected to have made. +// +// Ranging over rb.Sent() and asserting only inside an `if call.Method == ...` +// makes the assertion vacuous: a handler that returns early and never calls +// Telegram at all satisfies it, because the loop body never runs. Requiring +// exactly one call is what makes these tests fail when the call disappears. +func addedStickerPayload(t *testing.T, rb *testutil.RecordingBot) string { + t.Helper() + var payloads []string + for _, call := range rb.Sent() { + if call.Method == "addStickerToSet" { + payloads = append(payloads, call.Form["sticker"]) + } + } + if len(payloads) != 1 { + t.Fatalf("addStickerToSet calls = %d, want exactly 1", len(payloads)) + } + return payloads[0] +} + +func TestAddSticker_EmojiPrecedence(t *testing.T) { + cases := []struct { + name string + text string + want string + }{ + {"explicit wins", "/addsticker 🔥", "🔥"}, + {"inherits from replied sticker", "/addsticker", "🎉"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 0) + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply(tc.text, otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if payload := addedStickerPayload(t, rb); !strings.Contains(payload, tc.want) { + t.Errorf("sticker payload %q, want emoji %q", payload, tc.want) + } + }) + } +} + +// With no emoji anywhere, the default keeps the call valid — Telegram rejects +// an empty emoji_list. +func TestAddSticker_FallsBackToDefaultEmoji(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 0) + + upd := stickerReply("/addsticker", otherSet) + upd.Message.ReplyToMessage.Sticker.Emoji = "" + if err := s.handleAddSticker(context.Background(), rb.Bot, upd); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if payload := addedStickerPayload(t, rb); !strings.Contains(payload, defaultEmoji) { + t.Errorf("sticker payload %q, want the default emoji", payload) + } +} + +// A stray word is caught by parseEmoji. With no pack argument left, there is +// nothing else it could have been mistaken for. +func TestAddSticker_RejectsNonEmojiArgument(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 0) + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker mypack", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if countMethod(rb, "addStickerToSet") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } + if !strings.Contains(rb.LastSent().Text(), "not an emoji") { + t.Errorf("reply = %q, want an emoji usage error", rb.LastSent().Text()) + } +} + +func TestAddSticker_NoPackYet(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if countMethod(rb, "addStickerToSet") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } + if !strings.Contains(rb.LastSent().Text(), "/newpack") { + t.Errorf("reply = %q, want it to point at /newpack", rb.LastSent().Text()) + } +} + +// A pending record means an unfinished /newpack: there is no usable pack yet, +// and the reply has to say how to finish it. +func TestAddSticker_PendingPackIsNotUsable(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + pack := seedPack(t, s, 0) + pack.Pending = true + if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil { + t.Fatalf("seed pending: %v", err) + } + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if countMethod(rb, "addStickerToSet") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } + if !strings.Contains(rb.LastSent().Text(), "incomplete") { + t.Errorf("reply = %q, want the incomplete-pack hint", rb.LastSent().Text()) + } +} + +func TestAddSticker_FullPack(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERS_TOO_MUCH") + s := newTestState() + seedPack(t, s, maxStickersPerPack) + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if !strings.Contains(rb.LastSent().Text(), "full") { + t.Errorf("reply = %q, want the pack-is-full message", rb.LastSent().Text()) + } + // A failed add must not move the count. + pack, _ := loadPack(t, s) + if pack.Count != maxStickersPerPack { + t.Errorf("Count = %d, want it unchanged at %d", pack.Count, maxStickersPerPack) + } +} + +func TestDelSticker_HappyPath(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + + if countMethod(rb, "deleteStickerFromSet") != 1 { + t.Fatalf("methods = %v, want exactly one deleteStickerFromSet", methodsSent(rb)) + } + pack, found := loadPack(t, s) + if !found { + t.Fatal("pack record deleted by a successful /delsticker") + } + if pack.Count != 2 { + t.Errorf("Count = %d, want 2", pack.Count) + } +} + +// R7: a transient failure must never destroy the record. The probe that would +// have done so was removed for exactly this reason. +func TestDelSticker_TransientErrorKeepsRecord(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethod("deleteStickerFromSet", 500, `{"ok":false,"description":"server exploded"}`) + s := newTestState() + seedPack(t, s, 3) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + pack, found := loadPack(t, s) + if !found { + t.Fatal("a transient error deleted the pack record") + } + if pack.Count != 3 { + t.Errorf("Count = %d, want it unchanged at 3", pack.Count) + } +} + +// The other half of the same rule: a *positive* STICKERSET_INVALID is the one +// signal that authorises dropping the record, and dropping it is what unblocks +// /newpack. +func TestDelSticker_SetGoneDropsRecord(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethodCode("deleteStickerFromSet", 400, "Bad Request: STICKERSET_INVALID") + s := newTestState() + seedPack(t, s, 1) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + if _, found := loadPack(t, s); found { + t.Error("record survived a positive STICKERSET_INVALID; /newpack stays blocked") + } +} + +// Deleting the last sticker may destroy the set Telegram-side, and /mypack +// makes no API calls so it cannot notice. The reply has to name the way out. +func TestDelSticker_EmptyPackNamesRecovery(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 1) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + text := rb.LastSent().Text() + if !strings.Contains(text, "/delpack") { + t.Errorf("reply = %q, want it to name /delpack", text) + } + pack, _ := loadPack(t, s) + if pack.Count != 0 { + t.Errorf("Count = %d, want 0", pack.Count) + } +} + +// Count is floored: a drifted record must not go negative. +func TestDelSticker_CountFlooredAtZero(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 0) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + pack, _ := loadPack(t, s) + if pack.Count != 0 { + t.Errorf("Count = %d, want 0", pack.Count) + } +} + +func TestEditSticker_HappyPath(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 1) + + if err := s.handleEditSticker(context.Background(), rb.Bot, stickerReply("/editsticker 😂🔥", testSet)); err != nil { + t.Fatalf("handleEditSticker: %v", err) + } + if countMethod(rb, "setStickerEmojiList") != 1 { + t.Fatalf("methods = %v, want one setStickerEmojiList", methodsSent(rb)) + } +} + +// An empty emoji_list is invalid, so unlike /addsticker this cannot fall back +// to a default — the user has to say what they want. +func TestEditSticker_RequiresEmoji(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 1) + + if err := s.handleEditSticker(context.Background(), rb.Bot, stickerReply("/editsticker", testSet)); err != nil { + t.Fatalf("handleEditSticker: %v", err) + } + if countMethod(rb, "setStickerEmojiList") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } +} + +func TestOrderSticker(t *testing.T) { + cases := []struct { + name string + text string + wantAPI int + }{ + {"zero is valid", "/ordersticker 0", 1}, + // Not bounded locally: Telegram validates against the current set size + // and a local copy would go stale. + {"large position reaches the API", "/ordersticker 999", 1}, + {"negative rejected locally", "/ordersticker -1", 0}, + {"non-numeric rejected locally", "/ordersticker first", 0}, + {"missing argument rejected locally", "/ordersticker", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 5) + + if err := s.handleOrderSticker(context.Background(), rb.Bot, stickerReply(tc.text, testSet)); err != nil { + t.Fatalf("handleOrderSticker: %v", err) + } + if got := countMethod(rb, "setStickerPositionInSet"); got != tc.wantAPI { + t.Errorf("setStickerPositionInSet calls = %d, want %d (methods %v)", got, tc.wantAPI, methodsSent(rb)) + } + }) + } +} + +// The self-heal path frees the name too: STICKERSET_INVALID is a positive +// "this set is gone", so there is nothing left for the reservation to protect. +func TestSelfHeal_ReleasesTheName(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERSET_INVALID") + s := newTestState() + seedPack(t, s, 2) + ctx := context.Background() + + if err := s.handleAddSticker(ctx, rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if _, found := loadPack(t, s); found { + t.Error("pack record survived a positive STICKERSET_INVALID") + } + if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { + t.Error("the name is still reserved after the set was confirmed gone") + } +} diff --git a/internal/modules/sticker/image.go b/internal/modules/sticker/image.go new file mode 100644 index 0000000..28584eb --- /dev/null +++ b/internal/modules/sticker/image.go @@ -0,0 +1,162 @@ +package sticker + +import ( + "bytes" + "image" + "image/png" + + // Registered for their side effect: image.Decode needs the formats + // Telegram actually delivers. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" // read-only WEBP decoder + + "github.com/tiennm99/miti99bot/internal/log" +) + +const ( + // stickerEdge is Telegram's requirement: one side exactly 512px, the other + // at most 512px. + stickerEdge = 512 + // thumbnailEdge is the pack thumbnail requirement: exactly 100x100. + thumbnailEdge = 100 + + // maxDecodeDimension bounds peak allocation. Checked via DecodeConfig, + // before any pixel buffer exists. + // + // Note the 2 MB source cap bounds *compressed* bytes and so does not bound + // this: a flat-colour 4096x4096 PNG is a few tens of KB. At this cap a + // single conversion peaks near 90 MB live — the decoded source, the NRGBA + // scale target, and the PNG encode buffer coexist. Handlers run one at a + // time, so this is RSS pressure rather than a multiplier. + maxDecodeDimension = 4096 + + // softMaxStickerBytes is a *client-side* ceiling, not a documented limit. + // The widely-repeated 512 KB figure appears in no current official page, so + // it must never be stated to users as a rule. + softMaxStickerBytes = 512 << 10 +) + +// toStickerPNG converts an arbitrary image to a PNG sized for a static sticker: +// long edge exactly 512, aspect ratio preserved. +func toStickerPNG(src []byte) ([]byte, error) { + img, err := decodeBounded(src) + if err != nil { + return nil, err + } + + b := img.Bounds() + w, h := scaleToLongEdge(b.Dx(), b.Dy(), stickerEdge) + out := resize(img, w, h) + + data, err := encodePNG(out, png.DefaultCompression) + if err != nil { + return nil, err + } + if len(data) <= softMaxStickerBytes { + return data, nil + } + + // Try harder before losing resolution. + if data, err = encodePNG(out, png.BestCompression); err == nil && len(data) <= softMaxStickerBytes { + return data, nil + } + // Then step the long edge down. Below 320 the sticker is too small to be + // worth shrinking further; hand back the best effort instead. + // + // Each rung resamples the already-scaled 512px image, not the source. The + // source may be 4096x4096, and resampling it once per rung made the ladder + // cost four full-size resamples instead of one — seconds of uninterruptible + // CPU on a dispatcher that runs handlers one at a time, so a single user + // could stall the bot for everyone. Target dimensions still come from the + // original ratio, so the aspect is identical either way. + for _, edge := range []int{448, 384, 320} { + w, h = scaleToLongEdge(b.Dx(), b.Dy(), edge) + candidate, encErr := encodePNG(resize(out, w, h), png.BestCompression) + if encErr != nil { + return nil, encErr + } + data = candidate + if len(data) <= softMaxStickerBytes { + return data, nil + } + } + log.Error("sticker_image_oversized", "bytes", len(data)) + return data, nil +} + +// toThumbnailPNG converts an image to a pack thumbnail: exactly 100x100, with +// the short edge padded transparently so the aspect ratio survives. +func toThumbnailPNG(src []byte) ([]byte, error) { + img, err := decodeBounded(src) + if err != nil { + return nil, err + } + + b := img.Bounds() + w, h := scaleToLongEdge(b.Dx(), b.Dy(), thumbnailEdge) + scaled := resize(img, w, h) + + canvas := image.NewNRGBA(image.Rect(0, 0, thumbnailEdge, thumbnailEdge)) + offset := image.Pt((thumbnailEdge-w)/2, (thumbnailEdge-h)/2) + draw.Draw(canvas, scaled.Bounds().Add(offset), scaled, image.Point{}, draw.Src) + + return encodePNG(canvas, png.BestCompression) +} + +// decodeBounded reads the header first and refuses oversized images before any +// pixel buffer is allocated. +func decodeBounded(src []byte) (image.Image, error) { + cfg, _, err := image.DecodeConfig(bytes.NewReader(src)) + if err != nil { + return nil, refuse("That file is not an image Telegram can use. Send a PNG, JPEG or WEBP.") + } + if cfg.Width > maxDecodeDimension || cfg.Height > maxDecodeDimension { + return nil, refuse("That image is too big to process. Keep both sides under 4096 pixels.") + } + if cfg.Width <= 0 || cfg.Height <= 0 { + // Not "too large" — a header claiming no pixels at all. The user can + // act on this, so it must be a userError rather than surfacing as a + // generic failure with an ERROR log line. + return nil, refuse("That image has no usable dimensions. Send a normal PNG, JPEG or WEBP.") + } + img, _, err := image.Decode(bytes.NewReader(src)) + if err != nil { + return nil, refuse("That image could not be read. Send a PNG, JPEG or WEBP.") + } + return img, nil +} + +// scaleToLongEdge returns the dimensions that put the long edge exactly at +// edge, keeping the aspect ratio. The short edge is clamped to at least 1 so an +// extreme aspect ratio cannot produce a zero-dimension image. +func scaleToLongEdge(w, h, edge int) (int, int) { + if w <= 0 || h <= 0 { + return edge, edge + } + if w >= h { + short := int(float64(h)*float64(edge)/float64(w) + 0.5) + return edge, max(short, 1) + } + short := int(float64(w)*float64(edge)/float64(h) + 0.5) + return max(short, 1), edge +} + +// resize scales into a fresh NRGBA, which preserves alpha. +func resize(img image.Image, w, h int) *image.NRGBA { + out := image.NewNRGBA(image.Rect(0, 0, w, h)) + draw.CatmullRom.Scale(out, out.Bounds(), img, img.Bounds(), draw.Over, nil) + return out +} + +func encodePNG(img image.Image, level png.CompressionLevel) ([]byte, error) { + var buf bytes.Buffer + enc := png.Encoder{CompressionLevel: level} + if err := enc.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/modules/sticker/image_test.go b/internal/modules/sticker/image_test.go new file mode 100644 index 0000000..9fe578a --- /dev/null +++ b/internal/modules/sticker/image_test.go @@ -0,0 +1,156 @@ +package sticker + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" +) + +// makePNG generates a test image in-process, so no binary fixtures are +// committed and every case is readable from the test itself. +func makePNG(t *testing.T, w, h int, alpha uint8) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: alpha}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode fixture: %v", err) + } + return buf.Bytes() +} + +func decodeSize(t *testing.T, data []byte) (int, int) { + t.Helper() + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode result: %v", err) + } + return cfg.Width, cfg.Height +} + +// Telegram requires one side to be exactly 512 and the other at most 512. +func TestToStickerPNG_Geometry(t *testing.T) { + cases := []struct { + name string + w, h int + wantW, wantH int + }{ + {"landscape", 1024, 512, 512, 256}, + {"portrait", 300, 900, 171, 512}, + {"square", 512, 512, 512, 512}, + {"upscales small input", 64, 32, 512, 256}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := toStickerPNG(makePNG(t, tc.w, tc.h, 255)) + if err != nil { + t.Fatalf("toStickerPNG: %v", err) + } + gotW, gotH := decodeSize(t, out) + if gotW != tc.wantW || gotH != tc.wantH { + t.Errorf("%dx%d -> %dx%d, want %dx%d", tc.w, tc.h, gotW, gotH, tc.wantW, tc.wantH) + } + if gotW != stickerEdge && gotH != stickerEdge { + t.Errorf("neither side is exactly %d: %dx%d", stickerEdge, gotW, gotH) + } + }) + } +} + +// An extreme aspect ratio must not round the short edge down to zero, which +// would produce an invalid image rather than an error. +// +// 1x4000 rather than the plan's 1x5000: 5000 is past maxDecodeDimension, so +// that case never reaches the scaler at all — it is rejected by the guard +// below. The clamp still needs exercising, and this is the most extreme ratio +// that actually gets there (512/4000 rounds to 0 before clamping). +func TestToStickerPNG_ExtremeAspectRatio(t *testing.T) { + out, err := toStickerPNG(makePNG(t, 1, 4000, 255)) + if err != nil { + t.Fatalf("toStickerPNG: %v", err) + } + w, h := decodeSize(t, out) + if w < 1 || h < 1 { + t.Fatalf("got a zero-dimension image: %dx%d", w, h) + } + if h != stickerEdge { + t.Errorf("long edge = %d, want %d", h, stickerEdge) + } +} + +// The other half: past the cap, the guard refuses rather than the scaler +// coping. The bound exists to cap allocation, so it must win over any +// aspect-ratio handling. +func TestToStickerPNG_ExtremeAspectPastCapIsRejected(t *testing.T) { + if _, err := toStickerPNG(makePNG(t, 1, maxDecodeDimension+904, 255)); err == nil { + t.Fatal("an image past the dimension cap was accepted") + } +} + +// Stickers are cut-outs; losing alpha in the resize would put a black box +// behind every one of them. +func TestToStickerPNG_PreservesAlpha(t *testing.T) { + out, err := toStickerPNG(makePNG(t, 256, 256, 0)) + if err != nil { + t.Fatalf("toStickerPNG: %v", err) + } + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if _, _, _, a := img.At(128, 128).RGBA(); a != 0 { + t.Errorf("alpha = %d at a fully transparent pixel, want 0", a) + } +} + +func TestToThumbnailPNG_IsExactly100Square(t *testing.T) { + // A non-square input still has to come out exactly 100x100, padded. + out, err := toThumbnailPNG(makePNG(t, 800, 400, 255)) + if err != nil { + t.Fatalf("toThumbnailPNG: %v", err) + } + w, h := decodeSize(t, out) + if w != thumbnailEdge || h != thumbnailEdge { + t.Errorf("thumbnail = %dx%d, want %dx%d", w, h, thumbnailEdge, thumbnailEdge) + } +} + +// The dimension guard runs on the header, before any pixel buffer exists — it +// is what bounds peak allocation for attacker-supplied images. +func TestDecodeBounded_RejectsOversizedDimensions(t *testing.T) { + oversized := makePNG(t, maxDecodeDimension+1, 4, 255) + if _, err := toStickerPNG(oversized); err == nil { + t.Fatal("toStickerPNG accepted an image past the dimension cap") + } +} + +func TestDecodeBounded_RejectsNonImage(t *testing.T) { + if _, err := toStickerPNG([]byte("this is not an image")); err == nil { + t.Fatal("toStickerPNG accepted non-image bytes") + } +} + +func TestScaleToLongEdge(t *testing.T) { + cases := []struct { + w, h, edge int + wantW, wantH int + }{ + {1000, 500, 512, 512, 256}, + {500, 1000, 512, 256, 512}, + {100, 100, 512, 512, 512}, + {5000, 1, 512, 512, 1}, // clamped, never 0 + } + for _, tc := range cases { + gotW, gotH := scaleToLongEdge(tc.w, tc.h, tc.edge) + if gotW != tc.wantW || gotH != tc.wantH { + t.Errorf("scaleToLongEdge(%d, %d, %d) = (%d, %d), want (%d, %d)", + tc.w, tc.h, tc.edge, gotW, gotH, tc.wantW, tc.wantH) + } + } +} diff --git a/internal/modules/sticker/pack.go b/internal/modules/sticker/pack.go new file mode 100644 index 0000000..edd56f2 --- /dev/null +++ b/internal/modules/sticker/pack.go @@ -0,0 +1,104 @@ +// Package sticker lets any user create and manage one personal Telegram +// sticker pack through the bot. The pack is created on behalf of the calling +// user, named "_by_", and stays bot-manageable because the +// bot created it. +// +// One pack per user is the central simplification: no command except /newpack +// takes a pack argument, because there is only ever one pack to act on. +package sticker + +import ( + "context" + "errors" + "strconv" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +// Pack is the single bot-created sticker set owned by a Telegram user. +// +// The record is keyed by owner ID alone, which makes the lookup itself the +// ownership check: there is no way to read a pack without naming its owner. +type Pack struct { + Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL + Name string `bson:"name"` // Telegram set name, "_by_" + Title string `bson:"title"` // display title, mutable via /renamepack + OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to + Count int `bson:"count"` // stickers in the set; keeps /mypack API-free + Pending bool `bson:"pending"` // write-ahead intent; see newpack's state machine + CreatedAt int64 `bson:"createdAt"` // unix millis +} + +// PackStore is the module's view over its collection. +type PackStore = storage.DocStore[Pack] + +// SlugReservation records which user claimed a pack name, globally. +// +// Pack records are keyed by owner, so they can only answer "does *this* user +// have a pack" — they cannot answer "who holds this name". Without that second +// question the module cannot tell its own interrupted attempt from a set +// belonging to someone else, because both look identical from the caller's +// side: a pending record naming a set that exists. +// +// The reservation answers the question the pack record cannot: who is entitled +// to create under a given name. It is claimed with a create-only write before +// Telegram is touched, so the first claimant wins the name and everyone else is +// refused before any set exists. +// +// It is NOT ownership of whatever set may already sit under that name. Nothing +// in this module grants that, because nothing stored here survives the wipe +// that would make the grant necessary. +type SlugReservation struct { + Slug string `bson:"slug"` + OwnerID int64 `bson:"ownerId"` + CreatedAt int64 `bson:"createdAt"` +} + +// SlugStore is the third typed view over the module's collection. +type SlugStore = storage.DocStore[SlugReservation] + +// slugKey namespaces reservations away from the owner-keyed Pack records. +// Pack keys are decimal owner IDs, so the prefix cannot collide. +func slugKey(slug string) string { return slugPrefix + slug } + +const slugPrefix = "slug:" + +// getSlugReservation reads a name's reservation. Missing is not an error — it +// is the normal state for an unclaimed name. +func getSlugReservation(ctx context.Context, store SlugStore, slug string) (SlugReservation, bool, error) { + r, _, err := store.Get(ctx, slugKey(slug)) + if errors.Is(err, storage.ErrNotFound) { + return SlugReservation{}, false, nil + } + if err != nil { + return SlugReservation{}, false, err + } + return r, true, nil +} + +// packKey is the storage key for a user's pack: the owner ID and nothing else. +// +// One pack per user makes the slug unnecessary as a key component, which is +// what removes the prefix scan the multi-pack design needed. The module calls +// List nowhere. +func packKey(ownerID int64) string { return strconv.FormatInt(ownerID, 10) } + +// getPack reads the caller's pack. A missing record is not an error — it is the +// normal state for a user who has never run /newpack — so it reports found +// rather than returning storage.ErrNotFound for every caller to translate. +func getPack(ctx context.Context, store PackStore, ownerID int64) (Pack, bool, error) { + p, _, err := store.Get(ctx, packKey(ownerID)) + if errors.Is(err, storage.ErrNotFound) { + return Pack{}, false, nil + } + if err != nil { + return Pack{}, false, err + } + return p, true, nil +} + +// shareLink is the public URL of a pack. It is fixed at creation and cannot be +// changed: Telegram exposes no method to rename a set's short name. +func shareLink(setName string) string { + return "https://t.me/addstickers/" + setName +} diff --git a/internal/modules/sticker/pack_handlers.go b/internal/modules/sticker/pack_handlers.go new file mode 100644 index 0000000..6cfaa53 --- /dev/null +++ b/internal/modules/sticker/pack_handlers.go @@ -0,0 +1,614 @@ +package sticker + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/storage" +) + +const ( + newpackUsage = "Reply to a sticker with: /newpack \nEg: /newpack mypack My Pack" + // slugTaken answers a name held by anyone other than the caller. It says + // only "taken", never who holds it — the plan accepts that pack names are + // enumerable (share links are public), but there is no reason to confirm + // which are backed by real packs any more precisely than Telegram already + // does. + slugTaken = "That pack name is taken. Pick a different one." + noPackYet = "You don't have a pack yet. Reply to a sticker with /newpack to create one." + pendingMarker = "\n\n⚠️ This pack is incomplete — re-run the same /newpack command to finish it." +) + +// handleNewPack creates the caller's pack. +// +// The hard part is not the API call, it is surviving an interruption. The slug +// fixes a permanent public URL, so an attempt that dies between "Telegram +// created the set" and "we wrote it down" would strand that slug forever: the +// set exists, nobody's record points at it, and the user cannot recreate it. +// +// The fix is a write-ahead record, claimed before Telegram is called, in two +// parts that answer two different questions: +// +// - a global reservation on the name — "who claimed this name?" (reserveSlug) +// - an owner-keyed pending Pack — "does this user have a pack?" (claimSlug) +// +// Both are needed. The pending record alone proves only that this caller *asked +// for* the name, which a user naming someone else's pack also does. The +// reservation settles who gets to create under a name; neither of them, nor the +// two together, is ever treated as permission to take over a set that already +// exists — see createPack. +func (s *state) handleNewPack(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + args := commandArgs(msg) + if len(args) < 2 { + return reply(ctx, b, msg, newpackUsage) + } + slug := strings.ToLower(args[0]) + if err := validateSlug(slug); err != nil { + return replyErr(ctx, b, msg, "sticker_newpack_slug", err) + } + title := strings.TrimSpace(strings.Join(args[1:], " ")) + if title == "" || len([]rune(title)) > maxTitleLen { + return reply(ctx, b, msg, fmt.Sprintf("Give a title of 1-%d characters.", maxTitleLen)) + } + + // The lock covers everything below, including the media leg. It is per-user, + // so a slow download delays only the user who sent it. + defer s.lockUser(ownerID)() + + // Refuse a caller who already has a finished pack BEFORE reserving anything, + // and before doing any expensive work. + // + // Reservations are permanent and global, so writing one and *then* + // discovering the caller is not entitled to a pack handed every user an + // unlimited name-burning primitive: each refused /newpack claimed a name for + // everyone else, at the cost of one message and zero API calls. The order of + // this check and reserveSlug is the whole defence. + // + // It also runs before resolveSource, which downloads, resamples and + // re-uploads an image. Answering "you already have a pack" is a single store + // read; making a user who cannot create a pack pay for the full media + // pipeline first was free work for anyone who wanted to spend the bot's CPU. + if existing, found, err := getPack(ctx, s.store, ownerID); err != nil { + log.Error("sticker_newpack_precheck", "err", err) + return reply(ctx, b, msg, genericFailure) + } else if found && !existing.Pending { + return reply(ctx, b, msg, fmt.Sprintf( + "You already have a pack (%s). Use /delpack first if you want a different one.\n%s", + existing.Slug, shareLink(existing.Name))) + } + + source, err := s.resolveSource(ctx, b, ownerID, msg) + if err != nil { + return replyErr(ctx, b, msg, "sticker_newpack_source", err) + } + + username, err := s.resolver.resolve(ctx, b) + if err != nil { + log.Error("sticker_newpack_username", "err", err) + return reply(ctx, b, msg, genericFailure) + } + setName, err := makeSetName(slug, username) + if err != nil { + return replyErr(ctx, b, msg, "sticker_newpack_setname", err) + } + + created, done, err := s.reserveSlug(ctx, b, msg, ownerID, slug) + if err != nil || done { + return err + } + + claimed, done, err := s.claimSlug(ctx, b, msg, ownerID, slug, setName, title) + if err != nil || done { + // Nothing downstream is holding this name: release it rather than + // leaving a permanent claim with no pack behind it. Only what this + // invocation created — never a reservation we merely resumed. + if created { + s.releaseSlug(ctx, ownerID, slug) + } + return err + } + + return s.createPack(ctx, b, msg, claimed, source) +} + +// reserveSlug claims a pack name for ownerID, globally and permanently. +// +// Pack records are keyed by owner, so they answer "does this user have a pack" +// and nothing else — a user naming someone else's slug produces exactly the +// same record as one resuming their own interrupted attempt. The reservation +// adds the missing fact about the *name*: who is entitled to create under it. +// +// A create-only write on the name itself is what keeps two users from racing +// for the same name: the first claimant wins it, and everyone else is refused +// before any set is created. It is deliberately NOT treated as proof of +// ownership over a set that already exists — see createPack for why no local +// fact can carry that weight. +// +// created reports whether this call wrote the reservation, so a caller that +// bails can release exactly what it made and never a reservation it merely +// resumed. done == true means the caller was already answered and the handler +// must stop. +func (s *state) reserveSlug(ctx context.Context, b *bot.Bot, msg *models.Message, ownerID int64, slug string) (created, done bool, err error) { + err = s.slugs.PutVersioned(ctx, slugKey(slug), 0, SlugReservation{ + Slug: slug, + OwnerID: ownerID, + CreatedAt: s.now().UnixMilli(), + }) + if err == nil { + return true, false, nil + } + if !errors.Is(err, storage.ErrConflict) { + log.Error("sticker_newpack_reserve", "err", err) + return false, true, reply(ctx, b, msg, genericFailure) + } + + held, found, getErr := getSlugReservation(ctx, s.slugs, slug) + if getErr != nil || !found { + // Conflict but unreadable: treat the name as unavailable rather than + // guessing. Guessing the other way is the takeover. + log.Error("sticker_newpack_reserve_read", "err", getErr) + return false, true, reply(ctx, b, msg, slugTaken) + } + if held.OwnerID != ownerID { + return false, true, reply(ctx, b, msg, slugTaken) + } + // Our own reservation from an earlier attempt: carry on and resume it, but + // do not claim we created it — releasing it on a later bail would discard a + // claim that predates this command. + return false, false, nil +} + +// releaseSlug frees a reservation whose pack was never created, or was deleted. +// +// Only ever called with a positive signal that the name is not in use — never on +// an unknown error, which would hand the name to whoever asks next while the set +// may still exist. +// +// It verifies ownership itself rather than trusting call sites. A bare +// delete-by-name is a cross-user primitive, and this module has already been +// bitten once by an ownership check that lived in the caller instead of the +// operation. +func (s *state) releaseSlug(ctx context.Context, ownerID int64, slug string) { + // The ownership read runs on the detached context too, not just the delete. + // Splitting them meant a cancelled request (SIGTERM, or the handler + // deadline) failed the read and returned before the delete — leaving a + // reservation with no pack and no set behind it, which no code path can + // ever reach again. A cleanup that only half-survives shutdown is worse + // than one that does not run at all. + commitCtx, cancel := commitContext(ctx) + defer cancel() + + held, found, err := getSlugReservation(commitCtx, s.slugs, slug) + if err != nil { + log.Error("sticker_release_slug_read", "slug", slug, "err", err) + return + } + if !found { + return + } + if held.OwnerID != ownerID { + log.Error("sticker_release_slug_refused", "slug", slug, "holder", held.OwnerID, "caller", ownerID) + return + } + if err := s.slugs.Delete(commitCtx, slugKey(slug)); err != nil && !errors.Is(err, storage.ErrNotFound) { + log.Error("sticker_release_slug", "slug", slug, "err", err) + } +} + +// claimSlug writes the write-ahead intent record, or interprets the conflict +// when the caller already has one. +// +// done == true means the caller was already answered and the handler must stop. +func (s *state) claimSlug(ctx context.Context, b *bot.Bot, msg *models.Message, ownerID int64, slug, setName, title string) (Pack, bool, error) { + intent := Pack{ + Slug: slug, + Name: setName, + Title: title, + OwnerID: ownerID, + Pending: true, + CreatedAt: s.now().UnixMilli(), + } + + // PutVersioned with expectedVersion 0 is create-only, and Mongo resolves it + // with a duplicate-key error, so exactly one writer wins. This record *is* + // the one-pack-per-user quota — there is no separate counter to keep in + // sync. Put would silently overwrite and must not be used here. + err := s.store.PutVersioned(ctx, packKey(ownerID), 0, intent) + if err == nil { + return intent, false, nil + } + if !errors.Is(err, storage.ErrConflict) { + log.Error("sticker_newpack_claim", "err", err) + return Pack{}, true, reply(ctx, b, msg, genericFailure) + } + + existing, found, getErr := getPack(ctx, s.store, ownerID) + if getErr != nil || !found { + log.Error("sticker_newpack_reread", "err", getErr) + return Pack{}, true, reply(ctx, b, msg, genericFailure) + } + + switch { + case !existing.Pending: + return Pack{}, true, reply(ctx, b, msg, fmt.Sprintf( + "You already have a pack (%s). Use /delpack first if you want a different one.\n%s", + existing.Slug, shareLink(existing.Name))) + + case existing.Slug == slug: + // Our own interrupted attempt for this exact name: resume it, but with + // the title from *this* command. Returning the stored record verbatim + // silently discarded a retyped title and then reported success using + // the old one — "/newpack mypack New Title" answering "Created Old." + resumed := existing + resumed.Title = intent.Title + // Name is deliberately NOT refreshed. It is derived from the bot's + // username, which can change at BotFather; the stored one names the set + // the interrupted attempt may already have created, and repointing it + // would orphan that set and aim later commands at a different name. + return resumed, false, nil + + default: + // An earlier attempt was interrupted under a *different* name. Probe + // before overwriting, so a set that attempt did create is logged as + // stranded rather than silently forgotten. + return s.resolveStaleIntent(ctx, b, msg, existing, intent) + } +} + +// resolveStaleIntent decides what to do with a pending record for a slug the +// caller is no longer asking for. +func (s *state) resolveStaleIntent(ctx context.Context, b *bot.Bot, msg *models.Message, existing, intent Pack) (Pack, bool, error) { + // Establish whether this caller still holds the old name before touching + // anything under it. They normally do — the pending record came from their + // own earlier run through reserveSlug — but it is re-read rather than + // inferred from the pending record. + held, found, err := getSlugReservation(ctx, s.slugs, existing.Slug) + if err != nil { + log.Error("sticker_newpack_stale_reservation", "err", err) + return Pack{}, true, reply(ctx, b, msg, genericFailure) + } + if !found || held.OwnerID != intent.OwnerID { + // Someone else holds the old name, or it was released. Either way the + // old intent is dead; replace it and let the caller proceed with the + // name they actually asked for. + if putErr := s.store.Put(ctx, packKey(intent.OwnerID), intent); putErr != nil { + log.Error("sticker_newpack_replace_intent", "err", putErr) + return Pack{}, true, reply(ctx, b, msg, genericFailure) + } + return intent, false, nil + } + + _, err = b.GetStickerSet(ctx, &bot.GetStickerSetParams{Name: existing.Name}) + switch { + case err == nil: + // The old name is occupied. This branch used to adopt, on the strength + // of a reservation it re-read here — and it never consulted the newer + // per-invocation guard at all, so it stayed a takeover route after that + // guard was added. No adoption happens anywhere in this module now. + // + // The old reservation stays: a set genuinely exists under that name, so + // the name is not free, and releasing it would only send the next + // caller down this same refusal. The dead intent is replaced so the + // caller can get on with the name they actually asked for. + if putErr := s.store.Put(ctx, packKey(intent.OwnerID), intent); putErr != nil { + log.Error("sticker_newpack_replace_intent", "err", putErr) + return Pack{}, true, reply(ctx, b, msg, genericFailure) + } + log.Error("sticker_newpack_stranded_set", "slug", existing.Slug, "owner", existing.OwnerID) + return intent, false, nil + + case isStickerSetMissing(err): + // Nothing was created under the old name; take over the record. A + // positive "no such set" is what makes releasing the old reservation + // safe — otherwise an abandoned name would be held against every other + // user forever, with no set behind it. + if putErr := s.store.Put(ctx, packKey(intent.OwnerID), intent); putErr != nil { + log.Error("sticker_newpack_replace_intent", "err", putErr) + return Pack{}, true, reply(ctx, b, msg, genericFailure) + } + s.releaseSlug(ctx, intent.OwnerID, existing.Slug) + return intent, false, nil + + default: + // Unknown failure: change nothing (plan rule 4). + log.Error("sticker_newpack_probe", "err", err) + return Pack{}, true, reply(ctx, b, msg, genericFailure) + } +} + +// createPack performs the Telegram-side creation for a claimed intent. +// +// It never takes over a set that already exists, for anybody, under any local +// evidence. Adoption was the source of four consecutive takeover holes, and the +// reason is structural rather than a bug that can be patched: every fact this +// module could use to prove "that set is mine to finish" lives in the same +// store that a restart on the in-memory backend erases, while the packs at +// Telegram survive. Once the proof is gone, a real interrupted attempt and an +// attacker naming a victim's public slug present identical evidence. +// +// Removing the branch removes the class. Nothing local can be forged into +// rights over an existing set, because no local fact grants them. +func (s *state) createPack(ctx context.Context, b *bot.Bot, msg *models.Message, pack Pack, source stickerSource) error { + _, err := b.GetStickerSet(ctx, &bot.GetStickerSetParams{Name: pack.Name}) + switch { + case err == nil: + // Occupied. Leave nothing behind: a pending record naming a set this + // caller does not own is itself a weapon, because /delpack deletes by + // set name and Telegram authorises that on any set this bot created — + // including the real owner's. + s.dropIntent(ctx, pack.OwnerID) + s.releaseSlug(ctx, pack.OwnerID, pack.Slug) + return reply(ctx, b, msg, slugTaken) + + case isStickerSetMissing(err): + // Free: create it. + + default: + // Unknown. Keep both the intent and the reservation: the set may exist, + // and re-running is how the user recovers. Destroying either here is + // what strands a slug (plan rule 4). + log.Error("sticker_newpack_lookup", "err", err) + return reply(ctx, b, msg, genericFailure) + } + + emoji := source.emoji + if len(emoji) == 0 { + emoji = []string{defaultEmoji} + } + _, err = b.CreateNewStickerSet(ctx, &bot.CreateNewStickerSetParams{ + UserID: pack.OwnerID, + Name: pack.Name, + Title: pack.Title, + Stickers: []models.InputSticker{{ + Sticker: source.fileID, + Format: stickerFormatStatic, + EmojiList: emoji, + }}, + }) + if err != nil { + // Only a refusal that proves nothing was created lets us undo the + // claim. On anything else the create may have succeeded server-side, so + // both the intent and the reservation stay — a re-run then reports the + // name as taken rather than guessing, which is the safe direction. + if createRefused(err) { + s.dropIntent(ctx, pack.OwnerID) + s.releaseSlug(ctx, pack.OwnerID, pack.Slug) + } + return replyAPIError(ctx, b, msg, "sticker_newpack_create", err) + } + return s.finishNewPack(ctx, b, msg, pack) +} + +// finishNewPack commits the confirmed record and replies with the share link. +func (s *state) finishNewPack(ctx context.Context, b *bot.Bot, msg *models.Message, pack Pack) error { + pack.Pending = false + if pack.Count == 0 { + pack.Count = 1 + } + if err := s.commitPack(ctx, pack); err != nil { + log.Error("sticker_newpack_commit", "err", err) + return reply(ctx, b, msg, genericFailure) + } + return reply(ctx, b, msg, fmt.Sprintf("Created %s.\n%s\n\nAdd more with /addsticker while replying to a sticker.", + pack.Title, shareLink(pack.Name))) +} + +// dropIntent removes a write-ahead record whose creation never happened, so the +// user is not left holding a slug for a set that does not exist. +func (s *state) dropIntent(ctx context.Context, ownerID int64) { + commitCtx, cancel := commitContext(ctx) + defer cancel() + if err := s.store.Delete(commitCtx, packKey(ownerID)); err != nil && !errors.Is(err, storage.ErrNotFound) { + log.Error("sticker_drop_intent", "user", ownerID, "err", err) + } +} + +// adjustCount applies a delta to the caller's sticker count and commits. +// +// It re-reads the record rather than trusting the copy the handler resolved +// earlier: that read happened *before* the per-user lock was taken, so writing +// a count derived from it would clobber any change made in between. Reading and +// writing both inside the lock is what makes the lock mean anything. +// +// Returns the committed record so the reply can quote the new count. +func (s *state) adjustCount(ctx context.Context, ownerID, delta int64) (Pack, error) { + // Detached: the sticker has already been added or removed at Telegram by + // the time this runs, so the read-modify-write that records it must not be + // abandoned because the request context expired mid-flight. + commitCtx, cancel := commitContext(ctx) + defer cancel() + + pack, found, err := getPack(commitCtx, s.store, ownerID) + if err != nil { + return Pack{}, err + } + if !found { + // The record vanished under us — nothing to update, and nothing that + // justifies recreating it. + return Pack{}, storage.ErrNotFound + } + pack.Count += int(delta) + if pack.Count < 0 { + // Count is advisory and drifts when a pack is edited through @Stickers. + // It must never go negative. + pack.Count = 0 + } + return pack, s.commitPack(commitCtx, pack) +} + +// commitPack writes a record that reflects a completed Telegram-side action, on +// a context detached from the request. See commitContext. +func (s *state) commitPack(ctx context.Context, pack Pack) error { + commitCtx, cancel := commitContext(ctx) + defer cancel() + return s.store.Put(commitCtx, packKey(pack.OwnerID), pack) +} + +// dropPackRecordIfSet deletes the caller's pack record only when it still names +// setName. +// +// dropPackRecord addresses a record by owner, which is right when the caller is +// acting on the pack they just resolved. It is wrong for a deferred action like +// a /delpack confirmation, which can be pressed after the record has moved on to +// a different pack — deleting by owner then destroys the record of a set that is +// very much alive. +func (s *state) dropPackRecordIfSet(ctx context.Context, ownerID int64, setName string) { + // Detached like the drop it guards: a cancelled read here would skip a + // cleanup that the set's confirmed deletion has already made mandatory. + commitCtx, cancel := commitContext(ctx) + defer cancel() + + pack, found, err := getPack(commitCtx, s.store, ownerID) + if err != nil { + log.Error("sticker_drop_record_check", "user", ownerID, "err", err) + return + } + if !found { + return + } + if !ownsSet(pack, setName) { + // The record already points at a different pack; leave it alone. + return + } + s.dropPackRecord(commitCtx, ownerID) +} + +// dropPackRecord deletes a pack record and frees the name it held. +// +// Every call site reaches here on a positive "this set is gone" signal — either +// isStickerSetMissing, or a confirmed DeleteStickerSet — so the name genuinely +// has no pack behind it any more and must return to the pool. Keeping it would +// shrink the global namespace permanently and let a /newpack + /delpack loop +// burn one name per cycle. +// +// If Telegram reserves deleted short names on its side (plan R11, unverified), +// releasing here is simply a no-op in practice: the next claimant reserves the +// name locally, then CreateNewStickerSet refuses with PACK_SHORT_NAME_OCCUPIED, +// which createRefused releases again. Either way the user gets a correct answer. +// +// Callers must never reach this on a transient failure. +func (s *state) dropPackRecord(ctx context.Context, ownerID int64) { + // Read before deleting: the record is the only thing that knows which name + // this owner held. The read shares the delete's detached context — on the + // request context it would fail during shutdown while the delete below + // still succeeded, stranding the name permanently. + commitCtx, cancel := commitContext(ctx) + defer cancel() + + pack, found, err := getPack(commitCtx, s.store, ownerID) + if err != nil { + log.Error("sticker_drop_record_read", "user", ownerID, "err", err) + // Still drop the record — leaving it would block /newpack — but the + // name cannot be freed without knowing it. + } + + if delErr := s.store.Delete(commitCtx, packKey(ownerID)); delErr != nil && !errors.Is(delErr, storage.ErrNotFound) { + log.Error("sticker_drop_record", "user", ownerID, "err", delErr) + return + } + + // Any outstanding /delpack confirmation was issued against the record just + // removed, so it no longer authorises anything. The callback re-checks too; + // this keeps a dead prompt from surviving to be pressed at all. + s.dropPendingDelete(commitCtx, pendingDeleteKey(ownerID)) + + if err == nil && found && pack.Slug != "" { + s.releaseSlug(commitCtx, ownerID, pack.Slug) + } +} + +// handleMyPack shows the caller's pack. Makes zero API calls: the count lives +// on the record, which is the whole reason it is stored. +func (s *state) handleMyPack(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + pack, found, err := getPack(ctx, s.store, ownerID) + if err != nil { + log.Error("sticker_mypack", "err", err) + return reply(ctx, b, msg, genericFailure) + } + if !found { + return reply(ctx, b, msg, noPackYet) + } + + text := fmt.Sprintf("%s (%s)\n%d sticker(s)\n%s", pack.Title, pack.Slug, pack.Count, shareLink(pack.Name)) + if pack.Pending { + // Showing an unfinished attempt beats hiding it: the user is blocked + // from /newpack until it resolves, and re-running the same command is + // what resolves it. + text += pendingMarker + } + return reply(ctx, b, msg, text) +} + +// handleRenamePack changes the pack's display title. The share link cannot +// follow — Telegram has no rename-short-name method. +func (s *state) handleRenamePack(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + title := strings.TrimSpace(commandArgText(msg)) + if title == "" || len([]rune(title)) > maxTitleLen { + return reply(ctx, b, msg, fmt.Sprintf("Usage: /renamepack \nGive a title of 1-%d characters.", maxTitleLen)) + } + + pack, found, err := getPack(ctx, s.store, ownerID) + if err != nil { + log.Error("sticker_renamepack_load", "err", err) + return reply(ctx, b, msg, genericFailure) + } + if !found || pack.Pending { + return reply(ctx, b, msg, noPackYet) + } + + defer s.lockUser(ownerID)() + + if _, err := b.SetStickerSetTitle(ctx, &bot.SetStickerSetTitleParams{Name: pack.Name, Title: title}); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_renamepack", err) + } + + pack.Title = title + if err := s.commitPack(ctx, pack); err != nil { + // The rename already happened on Telegram's side; only our copy of the + // title is stale, and the next successful rename fixes it. + log.Error("sticker_renamepack_commit", "err", err) + } + + // Naming the delete-and-recreate route turns a dead end into an answer. + // A user who typed "rename" with only a title is likely expecting the URL + // to follow, and it never can. + return reply(ctx, b, msg, fmt.Sprintf( + "Renamed to %s.\nThe link is unchanged: %s\n\nTo get a different link you have to /delpack and then /newpack under a new name — the stickers do not come along.", + title, shareLink(pack.Name))) +} diff --git a/internal/modules/sticker/pack_handlers_test.go b/internal/modules/sticker/pack_handlers_test.go new file mode 100644 index 0000000..eaa63bc --- /dev/null +++ b/internal/modules/sticker/pack_handlers_test.go @@ -0,0 +1,943 @@ +package sticker + +import ( + "context" + "strings" + "testing" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +const getMeResult = `{"id":7,"is_bot":true,"first_name":"Test","username":"testbot"}` + +// stubBotIdentity makes the username resolver work. The bot starts with +// WithSkipGetMe, so nothing populates a username until the module asks. +func stubBotIdentity(rb *testutil.RecordingBot) { rb.StubMethod("getMe", getMeResult) } + +// setMissing makes getStickerSet report the set does not exist — the only +// classification that lets the module treat a slug as free. +func setMissing(rb *testutil.RecordingBot) { + rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID") +} + +// setExists makes getStickerSet return a real set, which needs a struct result +// the bare harness cannot produce. +func setExists(rb *testutil.RecordingBot) { + rb.StubMethod("getStickerSet", `{"name":"`+testSet+`","title":"My Pack","sticker_type":"regular","stickers":[]}`) +} + +func TestNewPack_HappyPath(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setMissing(rb) + s := newTestState() + + if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + if countMethod(rb, "createNewStickerSet") != 1 { + t.Fatalf("methods = %v, want one createNewStickerSet", methodsSent(rb)) + } + pack, found := loadPack(t, s) + if !found { + t.Fatal("no pack record after a successful /newpack") + } + if pack.Pending { + t.Error("record is still Pending after success") + } + if pack.Name != testSet || pack.Count != 1 { + t.Errorf("pack = %+v, want name %q and count 1", pack, testSet) + } + if !strings.Contains(rb.LastSent().Text(), shareLink(testSet)) { + t.Errorf("reply = %q, want the share link", rb.LastSent().Text()) + } +} + +// The quota is the create-only write itself: there is no separate counter, so a +// second /newpack must lose on PutVersioned and never reach the API. +func TestNewPack_SecondPackRefusedWithoutAPICall(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + s := newTestState() + seedPack(t, s, 5) + + if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack another Another", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + if countMethod(rb, "createNewStickerSet") != 0 || countMethod(rb, "getStickerSet") != 0 { + t.Errorf("methods = %v, want no sticker-set API calls", methodsSent(rb)) + } + text := rb.LastSent().Text() + if !strings.Contains(text, "mypack") || !strings.Contains(text, "/delpack") { + t.Errorf("reply = %q, want it to name the existing slug and /delpack", text) + } +} + +// Re-running the same command after an interruption must complete the pack, +// not report the slug taken. This is what keeps a crash from stranding a +// permanent URL. +// An interrupted attempt whose set DOES exist is refused, not resumed. +// +// This used to adopt. Adoption is gone: no local fact can prove a set belongs +// to the caller, because the store holding that fact is exactly what a restart +// on the in-memory backend erases while the packs survive. The cost is that a +// crash between creation and commit strands the set; the benefit is that the +// same evidence cannot be manufactured by an attacker. +func TestNewPack_InterruptedAttemptWithLiveSetIsRefused(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setExists(rb) + s := newTestState() + ctx := context.Background() + + seedInterrupted(t, s, "mypack", testSet) + + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + if countMethod(rb, "createNewStickerSet") != 0 { + t.Errorf("methods = %v, want no create — a set already exists under that name", methodsSent(rb)) + } + if got := rb.LastSent().Text(); !strings.Contains(got, slugTaken) { + t.Errorf("reply = %q, want the name-taken refusal", got) + } + if pack, found := loadPack(t, s); found && !pack.Pending { + t.Errorf("adopted the existing set: %+v", pack) + } + // Nothing left behind: a pending record naming a set the caller may not own + // is a delete primitive, since /delpack deletes by set name. + if _, found := loadPack(t, s); found { + t.Error("refusal kept the intent — /delpack could then aim it at that set") + } + if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { + t.Error("refusal kept the reservation") + } +} + +// A pending record under a *different* slug whose set exists is likewise not +// adopted; the caller proceeds under the name they asked for, and the stranded +// name stays reserved because a set really does occupy it. +func TestNewPack_DifferentSlugDoesNotAdoptExistingSet(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setExists(rb) + s := newTestState() + ctx := context.Background() + + seedInterrupted(t, s, "oldslug", testSet) + + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newslug New", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + if pack, found := loadPack(t, s); found && pack.Slug == "oldslug" && !pack.Pending { + t.Errorf("adopted the old set: %+v", pack) + } + // The old name stays claimed: a set exists under it, so it is not free. + if _, held, _ := getSlugReservation(ctx, s.slugs, "oldslug"); !held { + t.Error("released a name that still has a set behind it") + } +} + +// Same shape, but nothing was created under the old name: the record is free to +// take over. +func TestNewPack_DifferentSlugReplacesDeadIntent(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setMissing(rb) + s := newTestState() + + seedInterrupted(t, s, "oldslug", "oldslug_by_testbot") + + if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + if countMethod(rb, "createNewStickerSet") != 1 { + t.Fatalf("methods = %v, want one create", methodsSent(rb)) + } + pack, _ := loadPack(t, s) + if pack.Slug != "mypack" || pack.Pending { + t.Errorf("pack = %+v, want a confirmed mypack record", pack) + } + // The old name had nothing behind it, so it must return to the pool. + // Without this assertion the test was named for a behaviour it never + // checked: every abandoned attempt would quietly shrink the namespace. + if _, held, _ := getSlugReservation(context.Background(), s.slugs, "oldslug"); held { + t.Error("the dead name stayed reserved with no set behind it") + } +} + +// An unclassifiable getStickerSet failure means "unknown". Guessing either way +// is what strands slugs or orphans sets, so the handler must change nothing. +func TestNewPack_UnknownLookupErrorAborts(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) + s := newTestState() + + if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + if countMethod(rb, "createNewStickerSet") != 0 { + t.Errorf("methods = %v, want no create after an unknown error", methodsSent(rb)) + } + // The intent and reservation must SURVIVE. "Unknown" means the set may + // exist; destroying either here is what permanently strands a slug. Keeping + // them is what makes re-running the same command recover. + pack, found := loadPack(t, s) + if !found || !pack.Pending { + t.Errorf("intent = (%+v, found=%v), want it kept and still pending for re-run recovery", pack, found) + } + if _, held, _ := getSlugReservation(context.Background(), s.slugs, "mypack"); !held { + t.Error("reservation dropped on an unknown error; another user could take the name while the set may exist") + } +} + +// Telegram itself refusing the name — NOT the "another user of this bot holds +// it" case, which the reservation now settles before any API call (see +// TestNewPack_ForeignReservationRefusedBeforeAnyAPICall). +// +// The reachable path here is a short name Telegram still reserves after a +// delete (plan R11): our reservation is free, GetStickerSet says missing, and +// createNewStickerSet refuses. A classified refusal proves nothing was created, +// so both the intent and the reservation are released for a retry. +func TestNewPack_OccupiedSlugDropsIntent(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setMissing(rb) + rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_OCCUPIED") + s := newTestState() + + if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + if _, found := loadPack(t, s); found { + t.Error("intent survived a rejected create") + } + if _, held, _ := getSlugReservation(context.Background(), s.slugs, "mypack"); held { + t.Error("reservation survived a classified refusal; the name would be held with no set behind it") + } + if !strings.Contains(rb.LastSent().Text(), "taken") { + t.Errorf("reply = %q, want the slug-taken message", rb.LastSent().Text()) + } +} + +func TestNewPack_ValidatesInput(t *testing.T) { + cases := []struct { + name string + text string + }{ + {"no arguments", "/newpack"}, + {"slug only", "/newpack mypack"}, + {"leading digit", "/newpack 1pack Title"}, + {"double underscore", "/newpack my__pack Title"}, + {"trailing underscore", "/newpack mypack_ Title"}, + {"too short", "/newpack ab Title"}, + {"title too long", "/newpack mypack " + strings.Repeat("x", maxTitleLen+1)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setMissing(rb) + s := newTestState() + + if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply(tc.text, otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + if countMethod(rb, "createNewStickerSet") != 0 { + t.Errorf("methods = %v, want rejection before any create", methodsSent(rb)) + } + if _, found := loadPack(t, s); found { + t.Error("a rejected /newpack wrote a record") + } + }) + } +} + +func TestMyPack_MakesNoAPICalls(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 7) + + upd := testutil.NewPrivateMessage(testUser, "/mypack") + if err := s.handleMyPack(context.Background(), rb.Bot, upd); err != nil { + t.Fatalf("handleMyPack: %v", err) + } + + for _, call := range rb.Sent() { + if call.Method != "sendMessage" { + t.Errorf("unexpected API call %q; /mypack must read only the store", call.Method) + } + } + text := rb.LastSent().Text() + for _, want := range []string{"My Pack", "mypack", "7", shareLink(testSet)} { + if !strings.Contains(text, want) { + t.Errorf("reply %q missing %q", text, want) + } + } +} + +// A stranded attempt is shown, not hidden: it blocks /newpack, and re-running +// the same command is what clears it. +func TestMyPack_ShowsPendingMarker(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + pack := seedPack(t, s, 0) + pack.Pending = true + if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil { + t.Fatalf("seed pending: %v", err) + } + + if err := s.handleMyPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/mypack")); err != nil { + t.Fatalf("handleMyPack: %v", err) + } + if !strings.Contains(rb.LastSent().Text(), "incomplete") { + t.Errorf("reply = %q, want the incomplete marker", rb.LastSent().Text()) + } +} + +func TestMyPack_NoPack(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + + if err := s.handleMyPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/mypack")); err != nil { + t.Fatalf("handleMyPack: %v", err) + } + if !strings.Contains(rb.LastSent().Text(), "/newpack") { + t.Errorf("reply = %q, want it to point at /newpack", rb.LastSent().Text()) + } +} + +// The link cannot follow a rename, so the reply has to name the only route to a +// different one — otherwise the user is left at a dead end. +func TestRenamePack_NamesTheURLChangeRoute(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 4) + + if err := s.handleRenamePack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/renamepack Better Name")); err != nil { + t.Fatalf("handleRenamePack: %v", err) + } + if countMethod(rb, "setStickerSetTitle") != 1 { + t.Fatalf("methods = %v, want one setStickerSetTitle", methodsSent(rb)) + } + text := rb.LastSent().Text() + for _, want := range []string{"Better Name", shareLink(testSet), "/delpack", "/newpack"} { + if !strings.Contains(text, want) { + t.Errorf("reply %q missing %q", text, want) + } + } + pack, _ := loadPack(t, s) + if pack.Title != "Better Name" { + t.Errorf("Title = %q, want the new title committed", pack.Title) + } +} + +func TestRenamePack_NoPack(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + + if err := s.handleRenamePack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/renamepack Whatever")); err != nil { + t.Fatalf("handleRenamePack: %v", err) + } + if countMethod(rb, "setStickerSetTitle") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } +} + +// Anonymous senders are refused before any store or API access, on every +// command. Telegram gives every anonymous admin the same From.ID, so without +// this they would all share one pack — and under one-pack-per-user, the first +// one to run /newpack would own it and block the rest. +func TestHandlers_RefuseAnonymousSenders(t *testing.T) { + handlers := map[string]struct { + text string + run func(*state, context.Context, *bot.Bot, *models.Update) error + }{ + "newpack": {"/newpack mypack My Pack", (*state).handleNewPack}, + "mypack": {"/mypack", (*state).handleMyPack}, + "addsticker": {"/addsticker 😂", (*state).handleAddSticker}, + "delsticker": {"/delsticker", (*state).handleDelSticker}, + "editsticker": {"/editsticker 😂", (*state).handleEditSticker}, + "ordersticker": {"/ordersticker 0", (*state).handleOrderSticker}, + "renamepack": {"/renamepack Title", (*state).handleRenamePack}, + "delpack": {"/delpack", (*state).handleDelPack}, + "setpackicon": {"/setpackicon", (*state).handleSetPackIcon}, + } + for name, h := range handlers { + t.Run(name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setMissing(rb) + s := newTestState() + seedPack(t, s, 3) + + upd := stickerReply(h.text, testSet) + upd.Message.SenderChat = &models.Chat{ID: -100} + + if err := h.run(s, context.Background(), rb.Bot, upd); err != nil { + t.Fatalf("%s: %v", name, err) + } + for _, call := range rb.Sent() { + if call.Method != "sendMessage" { + t.Errorf("%s called %q for an anonymous sender", name, call.Method) + } + } + if !strings.Contains(rb.LastSent().Text(), "personal account") { + t.Errorf("%s reply = %q, want the anonymous-sender refusal", name, rb.LastSent().Text()) + } + }) + } +} + +// seedInterrupted recreates the state a crashed /newpack leaves behind: a +// pending pack record AND the global name reservation that always precedes it. +// Seeding the record alone would build a state production cannot reach. +func seedInterrupted(t *testing.T, s *state, slug, setName string) { + t.Helper() + ctx := context.Background() + pending := Pack{Slug: slug, Name: setName, Title: "Old", OwnerID: testUser, Pending: true} + if err := s.store.Put(ctx, packKey(testUser), pending); err != nil { + t.Fatalf("seed pending: %v", err) + } + if err := s.slugs.Put(ctx, slugKey(slug), + SlugReservation{Slug: slug, OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed reservation: %v", err) + } +} + +// The pack-takeover regression. Share links are public, so any user can read a +// pack's slug off t.me and try to claim it. Before the global reservation, an +// attacker with no pack of their own reached createOrAdopt, found the victim's +// set existing, and adopted it — after which /delpack destroyed the victim's +// pack. +// +// The attacker must be refused, and must leave no trace: no adoption, no record +// of their own, and the victim's reservation untouched. +func TestNewPack_CannotSeizeAnotherUsersPack(t *testing.T) { + const victim, attacker = int64(1), int64(2) + + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setExists(rb) // the victim's set resolves + s := newTestState() + + ctx := context.Background() + if err := s.store.Put(ctx, packKey(victim), + Pack{Slug: "victimpack", Name: "victimpack_by_testbot", Title: "Victim", OwnerID: victim, Count: 9}); err != nil { + t.Fatalf("seed victim pack: %v", err) + } + if err := s.slugs.Put(ctx, slugKey("victimpack"), + SlugReservation{Slug: "victimpack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed victim reservation: %v", err) + } + + upd := stickerReply("/newpack victimpack Mine Now", otherSet) + upd.Message.From.ID = attacker + if err := s.handleNewPack(ctx, rb.Bot, upd); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + if !strings.Contains(rb.LastSent().Text(), "taken") { + t.Errorf("reply = %q, want the name refused as taken", rb.LastSent().Text()) + } + if got, found, _ := getPack(ctx, s.store, attacker); found { + t.Errorf("attacker now holds a pack record %+v — takeover succeeded", got) + } + held, _, _ := getSlugReservation(ctx, s.slugs, "victimpack") + if held.OwnerID != victim { + t.Errorf("reservation owner = %d, want the victim (%d)", held.OwnerID, victim) + } + // The victim's own record must be exactly as it was. + pack, found, _ := getPack(ctx, s.store, victim) + if !found || pack.Count != 9 || pack.Title != "Victim" { + t.Errorf("victim pack = (%+v, found=%v), want it untouched", pack, found) + } +} + +// The same protection has to hold for the resume path: a pending record whose +// slug is reserved by somebody else must not adopt either. +func TestNewPack_StaleIntentCannotAdoptForeignName(t *testing.T) { + const victim, attacker = int64(1), int64(2) + + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setExists(rb) + s := newTestState() + ctx := context.Background() + + // The attacker holds a pending record naming the victim's set, but the + // reservation belongs to the victim. + if err := s.store.Put(ctx, packKey(attacker), + Pack{Slug: "victimpack", Name: "victimpack_by_testbot", Title: "Old", OwnerID: attacker, Pending: true}); err != nil { + t.Fatalf("seed attacker intent: %v", err) + } + if err := s.slugs.Put(ctx, slugKey("victimpack"), + SlugReservation{Slug: "victimpack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed victim reservation: %v", err) + } + + upd := stickerReply("/newpack otherslug Other", otherSet) + upd.Message.From.ID = attacker + if err := s.handleNewPack(ctx, rb.Bot, upd); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + pack, _, _ := getPack(ctx, s.store, attacker) + if pack.Name == "victimpack_by_testbot" && !pack.Pending { + t.Errorf("attacker adopted the victim's set via the stale-intent path: %+v", pack) + } + held, _, _ := getSlugReservation(ctx, s.slugs, "victimpack") + if held.OwnerID != victim { + t.Errorf("reservation owner = %d, want the victim (%d)", held.OwnerID, victim) + } +} + +// F5's replacement: a name held by another user is now refused by the +// reservation, before any API call. The previous test of this name stubbed a +// combination (set missing + PACK_SHORT_NAME_OCCUPIED) that cannot occur for a +// set another user holds, so it never covered this case. +func TestNewPack_ForeignReservationRefusedBeforeAnyAPICall(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + s := newTestState() + ctx := context.Background() + + if err := s.slugs.Put(ctx, slugKey("mypack"), + SlugReservation{Slug: "mypack", OwnerID: 999, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed reservation: %v", err) + } + + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + for _, call := range rb.Sent() { + if call.Method != "sendMessage" && call.Method != "getMe" { + t.Errorf("called %q for a name held by another user; want refusal before any sticker API call", call.Method) + } + } + if !strings.Contains(rb.LastSent().Text(), "taken") { + t.Errorf("reply = %q, want the taken refusal", rb.LastSent().Text()) + } +} + +// The name-burning regression. Reservations are permanent and global, so +// writing one before establishing the caller is even entitled to a pack turned +// every refused /newpack into a free, unlimited denial primitive: no API call, +// no cost, and the name is gone for everyone else forever. +func TestNewPack_RefusedRunsClaimNoNames(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setMissing(rb) + s := newTestState() + ctx := context.Background() + + seedPack(t, s, 3) // the caller already has a finished pack + + for _, name := range []string{"memes", "funny", "cats"} { + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack "+name+" x", otherSet)); err != nil { + t.Fatalf("handleNewPack(%s): %v", name, err) + } + if !strings.Contains(rb.LastSent().Text(), "already have a pack") { + t.Fatalf("reply = %q, want the already-have-a-pack refusal", rb.LastSent().Text()) + } + if _, held, _ := getSlugReservation(ctx, s.slugs, name); held { + t.Errorf("refused /newpack claimed %q — every other user is now permanently denied that name", name) + } + } + + // Only the real pack's own name is reserved. + keys, err := s.slugs.List(ctx, slugPrefix) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(keys) != 1 { + t.Errorf("reservations = %d (%v), want exactly the one backing the real pack", len(keys), keys) + } +} + +// A name is also not burned when the *set name* is unusable, or when anything +// else makes the command bail after reserving. +func TestNewPack_ReservationReleasedWhenClaimFails(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setMissing(rb) + rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_INVALID") + s := newTestState() + ctx := context.Background() + + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { + t.Error("reservation survived a refusal that proves nothing was created") + } + if _, found := loadPack(t, s); found { + t.Error("intent survived a refusal that proves nothing was created") + } +} + +// These two are the only coverage of the `created` flag itself. +// +// A predecessor named ...ResumedReservationSurvivesABail sat here and did not +// test its name: its bail happened after claimSlug, which never consults the +// flag, so it passed with the whole distinction deleted. It duplicated +// TestNewPack_UnknownLookupErrorAborts and has been removed. +// +// Both drive a bail *inside* claimSlug, which is the single place handleNewPack +// consults `created`. The other reservation tests bail later — in createOrAdopt +// — where a different mechanism (createRefused) does the releasing, so they +// pass with the `created` guard removed entirely and cannot pin it. +// +// Reaching claimSlug's bail takes a pending record under a *different* slug: +// that sends claimSlug into resolveStaleIntent, which gives up when it cannot +// establish what happened to the old set. +func seedStaleIntentBail(t *testing.T, rb *testutil.RecordingBot, s *state) { + t.Helper() + stubBotIdentity(rb) + // Unknown failure probing the *old* set: resolveStaleIntent refuses to + // guess, so handleNewPack bails holding whatever reserveSlug just did. + rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) + seedInterrupted(t, s, "oldname", otherSet) +} + +// Direction 1: a name this invocation reserved must not survive the bail. +// Without the release, every refused attempt burns a global name for everyone. +func TestNewPack_FreshReservationReleasedWhenClaimBails(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + ctx := context.Background() + seedStaleIntentBail(t, rb, s) + + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newname New Pack", testSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + if _, held, _ := getSlugReservation(ctx, s.slugs, "newname"); held { + t.Error("a name reserved by this invocation survived its bail — the name is now permanently denied to every other user, with no pack behind it") + } + if _, held, _ := getSlugReservation(ctx, s.slugs, "oldname"); !held { + t.Error("the pre-existing reservation was collateral damage") + } +} + +// Direction 2: a name the caller already held must survive the bail. Releasing +// it would hand a live claim to the next user to ask while the set may exist. +func TestNewPack_ResumedReservationNotReleasedWhenClaimBails(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + ctx := context.Background() + seedStaleIntentBail(t, rb, s) + + // The caller already holds "newname" from an earlier run, so reserveSlug + // resumes it rather than creating it. + if err := s.slugs.Put(ctx, slugKey("newname"), + SlugReservation{Slug: "newname", OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed prior reservation: %v", err) + } + + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newname New Pack", testSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + if _, held, _ := getSlugReservation(ctx, s.slugs, "newname"); !held { + t.Error("a reservation that predates this command was released on its bail — another user can now claim a name whose set may already exist") + } +} + +// A reservation is only proof of ownership while it outlives the sets it +// guards, and it does not: reservations live in our store, packs live at +// Telegram, and a restart on the in-memory backend wipes the former while every +// pack survives. This is the takeover of TestNewPack_CannotSeizeAnotherUsersPack +// replayed against an empty store, which is exactly what the attacker gets for +// free after any wipe. +// +// The set existing under a name this invocation has only just claimed proves +// the set is somebody else's: a real interrupted attempt reserved the name +// before creating the set, so it always finds its own reservation waiting. +func TestNewPack_WipedStoreCannotAdoptSurvivingPack(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setExists(rb) // the victim's pack outlived our store + s := newTestState() + ctx := context.Background() + + // Deliberately empty: no reservations, no pack records, nothing. + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + if got := rb.LastSent().Text(); !strings.Contains(got, slugTaken) { + t.Errorf("reply = %q, want the name-taken refusal", got) + } + if pack, found := loadPack(t, s); found && !pack.Pending { + t.Errorf("adopted a pack that survived the wipe: %+v — /delpack would now destroy its real owner's set", pack) + } + for _, call := range rb.Sent() { + if call.Method == "createNewStickerSet" || call.Method == "setStickerSetTitle" { + t.Errorf("refused adoption still called %s", call.Method) + } + } + // The refusal must not burn the name either: the set's real owner has to be + // able to re-register it after the same wipe. + if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { + t.Error("the refused attempt kept the reservation, denying the name to the set's actual owner") + } +} + +// The two-command takeover: no crash, no store error, two ordinary /newpack +// calls, and the attacker used to end up owning a stranger's pack. +// +// The first command's GetStickerSet is inconclusive (429, 5xx, a deadline), so +// the module correctly keeps the intent and the reservation — re-running is how +// a real user recovers. But that turned the attacker's *fresh* reservation into +// a *resumed* one, which defeated the per-invocation guard that was supposed to +// make adoption safe. The second identical command then adopted. +// +// The guard is gone; refusing outright is what closes this. The starting state +// is an empty store, which is what a restart on the in-memory backend leaves +// behind while every pack at Telegram survives. +func TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver(t *testing.T) { + s := newTestState() + ctx := context.Background() + + rb1 := testutil.NewRecordingBot(t) + stubBotIdentity(rb1) + rb1.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) + if err := s.handleNewPack(ctx, rb1.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil { + t.Fatalf("first /newpack: %v", err) + } + + // Fresh bot: Reset() deliberately keeps registered failures. + rb2 := testutil.NewRecordingBot(t) + stubBotIdentity(rb2) + setExists(rb2) + if err := s.handleNewPack(ctx, rb2.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil { + t.Fatalf("second /newpack: %v", err) + } + + if pack, found := loadPack(t, s); found && !pack.Pending { + t.Errorf("TAKEOVER: caller now owns %+v and can /delpack a set they never created", pack) + } + if got := rb2.LastSent().Text(); !strings.Contains(got, slugTaken) { + t.Errorf("reply = %q, want the name-taken refusal", got) + } +} + +// A pending record is not authority to delete a set. +// +// /newpack writes its intent before Telegram is called, so anyone can produce a +// pending record naming any set. DeleteStickerSet is keyed by set name and +// Telegram authorises it for every set this bot created — so confirming a +// delete from a pending record would let one user destroy another's pack, with +// no adoption needed at all. +func TestDelPack_PendingRecordDeletesNothingAtTelegram(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + ctx := context.Background() + + // What a post-wipe /newpack against a stranger's slug leaves behind. + seedInterrupted(t, s, "mypack", testSet) + + if err := s.handleDelPack(ctx, rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil { + t.Fatalf("handleDelPack: %v", err) + } + + if n := countMethod(rb, "deleteStickerSet"); n != 0 { + t.Errorf("deleteStickerSet calls = %d, want 0 — an unconfirmed record must not reach Telegram", n) + } + // It should still clean up locally, so the user is not wedged. + if _, found := loadPack(t, s); found { + t.Error("local record survived, so /newpack stays blocked") + } + if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { + t.Error("name stayed reserved with nothing behind it") + } +} + +// releaseSlug verifies the holder itself rather than trusting its callers. +// +// A bare delete-by-name is a cross-user primitive: it is reached from seven +// call sites, and one of them getting the owner wrong would hand a live name +// away while the set still exists. +func TestReleaseSlug_RefusesANameHeldBySomeoneElse(t *testing.T) { + s := newTestState() + ctx := context.Background() + const holder, caller = int64(1), int64(2) + + if err := s.slugs.Put(ctx, slugKey("mypack"), + SlugReservation{Slug: "mypack", OwnerID: holder, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed: %v", err) + } + + s.releaseSlug(ctx, caller, "mypack") + + held, found, err := getSlugReservation(ctx, s.slugs, "mypack") + if err != nil { + t.Fatalf("read back: %v", err) + } + if !found || held.OwnerID != holder { + t.Error("a non-holder released the name; the holder's set is still live and the name is now claimable") + } +} + +// The release must survive a cancelled request context. +// +// Both the ownership read and the delete run on a detached context. When only +// the delete was detached, a shutdown mid-handler failed the read and returned +// early — leaving a reservation with no pack and no set behind it, which no +// code path can reach again. +func TestReleaseSlug_CompletesOnACancelledContext(t *testing.T) { + s := newTestState() + seed := context.Background() + + if err := s.slugs.Put(seed, slugKey("mypack"), + SlugReservation{Slug: "mypack", OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed: %v", err) + } + + // The in-memory backend ignores context entirely, so cancelling one proves + // nothing against it — this assertion passed whether or not the code + // detached until the store was made to honour cancellation. + s.slugs = ctxHonouringSlugs{inner: s.slugs} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // as if SIGTERM landed mid-handler + + s.releaseSlug(ctx, testUser, "mypack") + + if _, held, _ := getSlugReservation(seed, s.slugs, "mypack"); held { + t.Error("reservation survived a cancelled release; the name is stranded permanently") + } +} + +// ctxHonouringSlugs makes a SlugStore respect context cancellation, which the +// in-memory backend does not. Needed to test anything about detached contexts: +// against the bare memory store the operation completes either way. +type ctxHonouringSlugs struct{ inner SlugStore } + +func (c ctxHonouringSlugs) Get(ctx context.Context, id string) (SlugReservation, int64, error) { + if err := ctx.Err(); err != nil { + return SlugReservation{}, 0, err + } + return c.inner.Get(ctx, id) +} + +func (c ctxHonouringSlugs) Put(ctx context.Context, id string, val SlugReservation) error { + if err := ctx.Err(); err != nil { + return err + } + return c.inner.Put(ctx, id, val) +} + +func (c ctxHonouringSlugs) PutVersioned(ctx context.Context, id string, expectedVersion int64, val SlugReservation) error { + if err := ctx.Err(); err != nil { + return err + } + return c.inner.PutVersioned(ctx, id, expectedVersion, val) +} + +func (c ctxHonouringSlugs) Delete(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + return c.inner.Delete(ctx, id) +} + +func (c ctxHonouringSlugs) List(ctx context.Context, prefix string) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return c.inner.List(ctx, prefix) +} + +// A record that goes away takes its outstanding confirmation with it. +// +// The callback re-checks authority anyway, so this is defence in depth — but an +// unpinned guard is how the last one rotted into a blocklist unnoticed. +func TestDropPackRecord_ClearsAnOutstandingConfirmation(t *testing.T) { + s := newTestState() + ctx := context.Background() + + seedPack(t, s, 3) + seedPendingDelete(t, s, nil) + + s.dropPackRecord(ctx, testUser) + + if _, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)); err == nil { + t.Error("confirmation outlived the record that authorised it") + } +} + +// Resuming an interrupted attempt must use the title from the command the user +// just sent, not the one stored by the attempt that failed. +// +// The stored record was returned verbatim, so a retyped title was silently +// discarded and the success message quoted the old one — "/newpack mypack New" +// answering "Created Old." +func TestNewPack_ResumeUsesTheTitleJustTyped(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setMissing(rb) // nothing was created last time, so this run creates it + s := newTestState() + ctx := context.Background() + + seedInterrupted(t, s, "mypack", testSet) // stored title is "Old" + + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack Brand New Title", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + pack, found := loadPack(t, s) + if !found { + t.Fatal("no record after a resumed create") + } + if pack.Title != "Brand New Title" { + t.Errorf("stored title = %q, want the one just typed", pack.Title) + } + if got := rb.LastSent().Text(); !strings.Contains(got, "Brand New Title") { + t.Errorf("reply = %q, want it to quote the title just typed", got) + } + for _, call := range rb.Sent() { + if call.Method == "createNewStickerSet" && call.Form["title"] != "Brand New Title" { + t.Errorf("created with title %q, want the one just typed", call.Form["title"]) + } + } +} + +// Resuming must not re-derive the set name. +// +// Pack.Name is built from the bot's username, which can change at BotFather. +// The stored name identifies the set the interrupted attempt may already have +// created; refreshing it from the current username would repoint the record at +// a name nothing exists under, orphaning that set and aiming every later +// command at the wrong one. ownsSet documents the same rule. +func TestNewPack_ResumeKeepsTheStoredSetName(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) // resolves to "testbot" + setMissing(rb) + s := newTestState() + ctx := context.Background() + + // The earlier attempt ran while the bot was called something else. + const legacySet = "mypack_by_oldbot" + seedInterrupted(t, s, "mypack", legacySet) + + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + pack, found := loadPack(t, s) + if !found { + t.Fatal("no record after resume") + } + if pack.Name != legacySet { + t.Errorf("set name = %q, want the stored %q — the earlier attempt's set is now orphaned", pack.Name, legacySet) + } +} diff --git a/internal/modules/sticker/pack_test.go b/internal/modules/sticker/pack_test.go new file mode 100644 index 0000000..9c2337c --- /dev/null +++ b/internal/modules/sticker/pack_test.go @@ -0,0 +1,71 @@ +package sticker + +import ( + "context" + "testing" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +func newTestStore(t *testing.T) PackStore { + t.Helper() + return storage.Typed[Pack](storage.NewMemoryProvider().Collection("sticker")) +} + +// Pack is persisted with its fields hoisted to the document root, so a bson tag +// colliding with a reserved root field would panic at startup. Typed panics on +// collision; constructing the store is the assertion. +func TestPack_NoReservedFieldCollision(t *testing.T) { + defer func() { + if rec := recover(); rec != nil { + t.Fatalf("Pack collides with a reserved storage field: %v", rec) + } + }() + _ = newTestStore(t) +} + +// The key is the owner ID alone, which is what makes the lookup itself the +// ownership check: there is no key shape that reads another user's pack. +func TestGetPack_IsolatesOwners(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + want := Pack{Slug: "alpha", Name: "alpha_by_bot", Title: "Alpha", OwnerID: 1, Count: 3} + if err := store.Put(ctx, packKey(1), want); err != nil { + t.Fatalf("put: %v", err) + } + + got, found, err := getPack(ctx, store, 1) + if err != nil || !found { + t.Fatalf("getPack(owner 1) = (%+v, %v, %v), want found", got, found, err) + } + if got.Slug != want.Slug || got.Count != want.Count { + t.Errorf("getPack(owner 1) = %+v, want %+v", got, want) + } + + other, found, err := getPack(ctx, store, 2) + if err != nil { + t.Fatalf("getPack(owner 2) error: %v", err) + } + if found { + t.Errorf("getPack(owner 2) returned owner 1's pack: %+v", other) + } +} + +// A user who has never run /newpack is the normal case, not an error worth +// propagating to every caller. +func TestGetPack_MissingIsNotAnError(t *testing.T) { + got, found, err := getPack(context.Background(), newTestStore(t), 404) + if err != nil { + t.Fatalf("getPack(unknown) error: %v", err) + } + if found { + t.Errorf("getPack(unknown) found %+v, want not found", got) + } +} + +func TestShareLink(t *testing.T) { + if got, want := shareLink("mypack_by_bot"), "https://t.me/addstickers/mypack_by_bot"; got != want { + t.Errorf("shareLink = %q, want %q", got, want) + } +} diff --git a/internal/modules/sticker/pending_delete.go b/internal/modules/sticker/pending_delete.go new file mode 100644 index 0000000..bc2acd9 --- /dev/null +++ b/internal/modules/sticker/pending_delete.go @@ -0,0 +1,103 @@ +package sticker + +import ( + "crypto/rand" + "encoding/hex" + "strconv" + "strings" + "time" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +const ( + // callbackPrefix owns this module's inline-button namespace. Checked + // bidirectionally against every other module's prefix at registry build. + callbackPrefix = "sticker_pack:" + // deleteCallbackPrefix is the confirm button for /delpack. + deleteCallbackPrefix = callbackPrefix + "d:" + // pendingDeletePrefix namespaces pending actions inside the collection the + // Pack records also live in. + pendingDeletePrefix = "pending-delete:" + + // pendingDeleteTTL is short on purpose. The stock module uses 24h for a + // non-destructive suggestion; deleting a pack is irreversible on Telegram's + // side, so the window to confirm is minutes, not a day. + pendingDeleteTTL = 10 * time.Minute + + // maxCallbackBytes is Telegram's cap on inline-button callback data. + maxCallbackBytes = 64 +) + +// PendingDeleteStore is the second typed view over the module's collection. +type PendingDeleteStore = storage.DocStore[PendingDelete] + +// PendingDelete is the server-side half of a /delpack confirm button. +// +// The payload in the button is an opaque id and nothing else. Everything that +// decides whether a press is legitimate — who, where, which message, until +// when — lives here, where the user cannot edit it. +type PendingDelete struct { + ID string `bson:"id"` + OwnerID int64 `bson:"ownerId"` + Slug string `bson:"slug"` + SetName string `bson:"setName"` + ChatID int64 `bson:"chatId"` + MessageID int `bson:"messageId"` + CreatedAt int64 `bson:"createdAt"` + ExpiresAt int64 `bson:"expiresAt"` +} + +// pendingDeleteKey is deterministic per user, so running /delpack twice +// supersedes the first prompt instead of leaving two independently valid +// delete capabilities in scrollback — the shape stock/pending_dividend.go +// already uses and documents. +// +// A random per-invocation key produced two live confirmations at once, which is +// worse than untidy: the stale one could be pressed after the pack it named was +// already deleted and a *different* pack created, and its "set is gone" result +// then cleared the new pack's record. +// +// It also bounds storage. Pending actions are only deleted when consumed, so a +// random key let anyone accumulate documents by running a public command and +// never tapping. +func pendingDeleteKey(ownerID int64) string { + return pendingDeletePrefix + strconv.FormatInt(ownerID, 10) +} + +// newActionID returns an unguessable id for a pending action. Guessability +// matters: the id is the entire contents of the callback payload. +func newActionID() (string, error) { + var buf [12]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + return hex.EncodeToString(buf[:]), nil +} + +// deleteCallbackData builds the button payload — a prefix plus the opaque id, +// well inside the 64-byte cap. +func deleteCallbackData(id string) string { return deleteCallbackPrefix + id } + +// parseDeleteCallback recovers the action id from client-controlled callback +// data. The id is only a lookup key; every authorisation check happens against +// the stored action. +func parseDeleteCallback(data string) (string, bool) { + if len(data) > maxCallbackBytes { + return "", false + } + id, ok := strings.CutPrefix(data, deleteCallbackPrefix) + if !ok || id == "" { + return "", false + } + for _, r := range id { + if !isHexDigit(r) { + return "", false + } + } + return id, true +} + +func isHexDigit(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') +} diff --git a/internal/modules/sticker/photo.go b/internal/modules/sticker/photo.go new file mode 100644 index 0000000..671808f --- /dev/null +++ b/internal/modules/sticker/photo.go @@ -0,0 +1,86 @@ +package sticker + +import ( + "bytes" + "context" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// imageDocumentMimes is the allowlist for a replied document. Anything else is +// rejected before a single byte is downloaded. +var imageDocumentMimes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, +} + +// resolvePhotoSource turns a replied photo or image document into an uploaded +// sticker file. +// +// Raw bytes cannot ride along on AddStickerToSet: the form builder honours +// attach:// only for []models.InputSticker, and the single InputSticker in +// AddStickerToSetParams falls through to a default that drops the attachment +// silently. So the image is uploaded first and the returned file_id is used. +func (s *state) resolvePhotoSource(ctx context.Context, b *bot.Bot, ownerID int64, replied *models.Message) (stickerSource, error) { + fileID, err := photoFileID(replied) + if err != nil { + return stickerSource{}, err + } + + // Reserve the caller's reply tail: everything from here to the upload is + // the slow leg. See mediaContext. + mediaCtx, cancelMedia := mediaContext(ctx) + defer cancelMedia() + + raw, err := downloadFile(mediaCtx, b, fileID) + if err != nil { + return stickerSource{}, err + } + png, err := toStickerPNG(raw) + if err != nil { + return stickerSource{}, err + } + + uploaded, err := b.UploadStickerFile(mediaCtx, &bot.UploadStickerFileParams{ + UserID: ownerID, + Sticker: &models.InputFileUpload{Filename: "sticker.png", Data: bytes.NewReader(png)}, + StickerFormat: stickerFormatStatic, + }) + if err != nil { + return stickerSource{}, err + } + // Consumed immediately, so the file_id's undocumented validity window never + // matters. Do not restructure this into upload-now-use-later. + return stickerSource{fileID: uploaded.FileID}, nil +} + +// photoFileID picks the file to convert from a replied message. +func photoFileID(replied *models.Message) (string, error) { + if len(replied.Photo) > 0 { + // Pick the largest by size rather than trusting the array's order. + best := replied.Photo[0] + for _, size := range replied.Photo[1:] { + if size.FileSize > best.FileSize { + best = size + } + } + if best.FileSize > maxSourceBytes { + return "", refuse("That image is too large — keep it under 2 MB.") + } + return best.FileID, nil + } + + if doc := replied.Document; doc != nil { + if !imageDocumentMimes[doc.MimeType] { + return "", refuse("That file is not a supported image. Send a PNG, JPEG or WEBP.") + } + if doc.FileSize > maxSourceBytes { + return "", refuse("That image is too large — keep it under 2 MB.") + } + return doc.FileID, nil + } + + return "", refuse("Reply to a sticker, photo, or image file with this command.") +} diff --git a/internal/modules/sticker/photo_test.go b/internal/modules/sticker/photo_test.go new file mode 100644 index 0000000..992a62e --- /dev/null +++ b/internal/modules/sticker/photo_test.go @@ -0,0 +1,86 @@ +package sticker + +import ( + "testing" + + "github.com/go-telegram/bot/models" +) + +// Source selection happens before any download, so an unsupported or oversized +// file costs nothing. +func TestPhotoFileID(t *testing.T) { + cases := []struct { + name string + replied *models.Message + want string + ok bool + }{ + { + // Sizes are picked by FileSize rather than array order. + name: "largest photo size wins", + replied: &models.Message{Photo: []models.PhotoSize{ + {FileID: "small", FileSize: 100}, + {FileID: "large", FileSize: 5000}, + {FileID: "medium", FileSize: 900}, + }}, + want: "large", ok: true, + }, + { + name: "png document", + replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/png"}}, + want: "doc", ok: true, + }, + { + name: "jpeg document", + replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/jpeg"}}, + want: "doc", ok: true, + }, + { + name: "webp document", + replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/webp"}}, + want: "doc", ok: true, + }, + { + name: "pdf rejected", + replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "application/pdf"}}, + ok: false, + }, + { + name: "gif document rejected", + replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/gif"}}, + ok: false, + }, + { + name: "oversized photo rejected", + replied: &models.Message{Photo: []models.PhotoSize{{FileID: "huge", FileSize: maxSourceBytes + 1}}}, + ok: false, + }, + { + name: "oversized document rejected", + replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/png", FileSize: maxSourceBytes + 1}}, + ok: false, + }, + { + name: "nothing usable", + replied: &models.Message{Text: "hello"}, + ok: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := photoFileID(tc.replied) + if tc.ok { + if err != nil { + t.Fatalf("photoFileID: %v", err) + } + if got != tc.want { + t.Errorf("file_id = %q, want %q", got, tc.want) + } + return + } + if err == nil { + t.Fatalf("photoFileID = %q, want a refusal", got) + } + }) + } +} diff --git a/internal/modules/sticker/resolve.go b/internal/modules/sticker/resolve.go new file mode 100644 index 0000000..87d0c09 --- /dev/null +++ b/internal/modules/sticker/resolve.go @@ -0,0 +1,120 @@ +package sticker + +import ( + "context" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// stickerFormatStatic is InputSticker.Format for this module. The whole module +// is static-only; animated and video packs are out of scope. +const stickerFormatStatic = "static" + +// notOwnedRefusal answers every "that sticker is not yours to manage" case. +// +// It is deliberately the same sentence whether the caller has no pack at all, +// or replied to a sticker from someone else's pack, or from a set this bot did +// not create. Distinct wording would answer "does this set belong to another +// user of this bot?" for any set the caller can find — a question they have no +// standing to ask. The uniformity is the feature; do not "improve" this into +// three specific messages. +// +// /newpack's slug-occupancy answer is a separate, accepted disclosure: a share +// link is publicly probeable without the bot, so it reveals nothing new. +const notOwnedRefusal = "That sticker is not in your pack. Reply to a sticker from your own pack — /mypack shows it." + +// usageReplyToSticker is the shared "you must reply to something" line. +const usageReplyToSticker = "Reply to a sticker with this command." + +// stickerSource is a resolved sticker ready to be added to a set: whatever the +// replied message carried, reduced to a file_id. +type stickerSource struct { + fileID string // usable directly as InputSticker.Sticker + emoji []string // inherited from a replied sticker; at most one element +} + +// ownedSticker is an existing sticker in the caller's own pack. +type ownedSticker struct { + fileID string + pack Pack +} + +// resolveSource turns the replied message into a sticker source. +// +// It takes ctx, b, and ownerID even though the sticker branch uses none of +// them: the photo branch (which resolves by downloading the image and calling +// UploadStickerFile) lives in this same function, and declaring the full +// signature up front keeps that from churning every call site. +func (s *state) resolveSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error) { + replied := msg.ReplyToMessage + if replied == nil { + return stickerSource{}, refuse(usageReplyToSticker) + } + + if st := replied.Sticker; st != nil { + if err := requireStaticSticker(st); err != nil { + return stickerSource{}, err + } + src := stickerSource{fileID: st.FileID} + if st.Emoji != "" { + // models.Sticker.Emoji is a single string, so a replied sticker + // contributes at most one emoji. + src.emoji = []string{st.Emoji} + } + return src, nil + } + + return s.resolvePhotoSource(ctx, b, ownerID, replied) +} + +// requireStaticSticker enforces the module's static-only scope on a sticker the +// bot is about to copy into a set. +// +// IsAnimated and IsVideo are the obvious half. Type is the half that is easy to +// miss: a mask sticker and a custom-emoji sticker are both static, and both are +// invalid in a regular sticker set, so the boolean pair alone would let them +// through to fail at the API with an opaque error. +func requireStaticSticker(st *models.Sticker) error { + if st.IsAnimated || st.IsVideo { + return refuse("This module handles static stickers only — that one is animated or video.") + } + if st.Type != "" && st.Type != "regular" { + return refuse("That is a mask or custom-emoji sticker, which cannot go in a regular pack.") + } + return nil +} + +// resolveOwned resolves a replied sticker that must already be in the caller's +// own pack. It is the single ownership gate for /delsticker, /editsticker, +// /ordersticker, and /setpackicon. +// +// Costs exactly one store Get and a string comparison — no List, no API call. +func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, error) { + replied := msg.ReplyToMessage + if replied == nil || replied.Sticker == nil { + return ownedSticker{}, refuse(usageReplyToSticker) + } + st := replied.Sticker + if st.SetName == "" { + return ownedSticker{}, refuse("That sticker does not belong to any pack.") + } + // Before the store read: a malformed reply costs nothing, and this keeps + // "rejected before any API call" true by construction. + if err := requireStaticSticker(st); err != nil { + return ownedSticker{}, err + } + + pack, found, err := getPack(ctx, s.store, ownerID) + if err != nil { + return ownedSticker{}, err + } + // Both branches answer identically — see notOwnedRefusal. + if !found || pack.Pending { + return ownedSticker{}, refuse(notOwnedRefusal) + } + if !ownsSet(pack, st.SetName) { + return ownedSticker{}, refuse(notOwnedRefusal) + } + return ownedSticker{fileID: st.FileID, pack: pack}, nil +} diff --git a/internal/modules/sticker/resolve_test.go b/internal/modules/sticker/resolve_test.go new file mode 100644 index 0000000..dad8b28 --- /dev/null +++ b/internal/modules/sticker/resolve_test.go @@ -0,0 +1,190 @@ +package sticker + +import ( + "context" + "testing" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +// resolveOwnedText runs the gate and returns the refusal the user would see. +func resolveOwnedText(t *testing.T, s *state, upd *models.Update) string { + t.Helper() + _, err := s.resolveOwned(context.Background(), upd.Message, testUser) + if err == nil { + t.Fatal("resolveOwned succeeded; want a refusal") + } + ue, ok := err.(userError) + if !ok { + t.Fatalf("err is %T (%v), want userError", err, err) + } + return ue.msg +} + +// The point of the uniform refusal: "you have no pack" and "that set is not +// yours" must be indistinguishable, or a user can probe which sets exist under +// this bot by elimination. +// +// This asserts the two replies against *each other* rather than against fixed +// strings, so rewording the copy cannot silently reintroduce the disclosure. +func TestResolveOwned_RefusalsAreIdentical(t *testing.T) { + noPack := newTestState() + noPackText := resolveOwnedText(t, noPack, stickerReply("/delsticker", otherSet)) + + withPack := newTestState() + seedPack(t, withPack, 2) + foreignText := resolveOwnedText(t, withPack, stickerReply("/delsticker", otherSet)) + + if noPackText != foreignText { + t.Errorf("refusals differ and leak whether a set exists:\n no pack: %q\n foreign: %q", noPackText, foreignText) + } +} + +// A pending record is not a usable pack, and must refuse identically too. +func TestResolveOwned_PendingRefusesIdentically(t *testing.T) { + noPack := newTestState() + noPackText := resolveOwnedText(t, noPack, stickerReply("/delsticker", otherSet)) + + pendingState := newTestState() + pack := seedPack(t, pendingState, 0) + pack.Pending = true + if err := pendingState.store.Put(context.Background(), packKey(testUser), pack); err != nil { + t.Fatalf("seed pending: %v", err) + } + pendingText := resolveOwnedText(t, pendingState, stickerReply("/delsticker", testSet)) + + if noPackText != pendingText { + t.Errorf("pending refusal differs:\n no pack: %q\n pending: %q", noPackText, pendingText) + } +} + +// Telegram echoes SetName with the casing the set was created with, so +// ownership has to fold case or a user's own pack stops resolving. +func TestResolveOwned_CaseInsensitiveSetName(t *testing.T) { + s := newTestState() + seedPack(t, s, 1) + + owned, err := s.resolveOwned(context.Background(), stickerReply("/delsticker", "MyPack_By_TestBot").Message, testUser) + if err != nil { + t.Fatalf("resolveOwned with differing case: %v", err) + } + if owned.pack.Name != testSet { + t.Errorf("pack.Name = %q, want %q", owned.pack.Name, testSet) + } +} + +func TestResolveOwned_UsageErrors(t *testing.T) { + s := newTestState() + seedPack(t, s, 1) + + t.Run("no reply", func(t *testing.T) { + upd := testutil.NewPrivateMessage(testUser, "/delsticker") + if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil { + t.Error("resolveOwned with no reply succeeded") + } + }) + + t.Run("reply is not a sticker", func(t *testing.T) { + upd := testutil.NewPrivateMessage(testUser, "/delsticker") + upd.Message.ReplyToMessage = &models.Message{Text: "hello"} + if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil { + t.Error("resolveOwned with a text reply succeeded") + } + }) + + t.Run("sticker has no set", func(t *testing.T) { + upd := stickerReply("/delsticker", testSet) + upd.Message.ReplyToMessage.Sticker.SetName = "" + if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil { + t.Error("resolveOwned with an empty set_name succeeded") + } + }) +} + +// The static-only gate. IsAnimated/IsVideo are the obvious half; Type is the +// half a boolean-only check misses — a mask sticker is static yet invalid in a +// regular set. +func TestRequireStaticSticker(t *testing.T) { + cases := []struct { + name string + sticker models.Sticker + ok bool + }{ + {"regular", models.Sticker{Type: "regular"}, true}, + {"type absent", models.Sticker{}, true}, + {"animated", models.Sticker{Type: "regular", IsAnimated: true}, false}, + {"video", models.Sticker{Type: "regular", IsVideo: true}, false}, + {"mask", models.Sticker{Type: "mask"}, false}, + {"custom emoji", models.Sticker{Type: "custom_emoji"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := requireStaticSticker(&tc.sticker) + if tc.ok && err != nil { + t.Errorf("requireStaticSticker = %v, want nil", err) + } + if !tc.ok && err == nil { + t.Error("requireStaticSticker = nil, want a refusal") + } + }) + } +} + +// Both entry points must enforce it: resolveSource is the one that can actually +// receive a non-static sticker, and neither may reach an API call. +func TestStaticGate_BlocksBothPathsBeforeAnyAPICall(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*models.Sticker) + setName string + }{ + {"animated source", func(st *models.Sticker) { st.IsAnimated = true }, otherSet}, + {"video source", func(st *models.Sticker) { st.IsVideo = true }, otherSet}, + {"mask source", func(st *models.Sticker) { st.Type = "mask" }, otherSet}, + {"animated owned", func(st *models.Sticker) { st.IsAnimated = true }, testSet}, + {"mask owned", func(st *models.Sticker) { st.Type = "mask" }, testSet}, + } { + t.Run(tc.name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 1) + + upd := stickerReply("/addsticker", tc.setName) + tc.mutate(upd.Message.ReplyToMessage.Sticker) + if err := s.handleAddSticker(context.Background(), rb.Bot, upd); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if countMethod(rb, "addStickerToSet") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } + + rb2 := testutil.NewRecordingBot(t) + if err := s.handleDelSticker(context.Background(), rb2.Bot, upd); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + if countMethod(rb2, "deleteStickerFromSet") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb2)) + } + }) + } +} + +// A replied sticker contributes at most one emoji, because models.Sticker.Emoji +// is a single string. +func TestResolveSource_InheritsSingleEmoji(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + + src, err := s.resolveSource(context.Background(), rb.Bot, testUser, stickerReply("/addsticker", otherSet).Message) + if err != nil { + t.Fatalf("resolveSource: %v", err) + } + if len(src.emoji) != 1 || src.emoji[0] != "🎉" { + t.Errorf("emoji = %q, want exactly one 🎉", src.emoji) + } + if src.fileID == "" { + t.Error("fileID is empty") + } +} diff --git a/internal/modules/sticker/sender.go b/internal/modules/sticker/sender.go new file mode 100644 index 0000000..6f74b6c --- /dev/null +++ b/internal/modules/sticker/sender.go @@ -0,0 +1,39 @@ +package sticker + +import ( + "errors" + + "github.com/go-telegram/bot/models" +) + +// errNoPersonalSender is returned when a message carries no usable personal +// identity. Handlers turn it into senderRefusal. +var errNoPersonalSender = errors.New("sticker: no personal sender") + +// senderRefusal explains the fix rather than only denying. Anonymous posting is +// a per-message toggle, so the user can act on this immediately. +const senderRefusal = "Sticker packs need a personal account. Turn off anonymous posting for this message and try again." + +// senderID returns the personal Telegram user behind msg. +// +// Every pack is keyed by this value, so it must identify one human. Telegram +// substitutes a single global GroupAnonymousBot user for *every* anonymous +// group-admin message and puts the real origin in SenderChat: without the +// SenderChat check, all anonymous admins across all groups would share one +// pack. Under one-pack-per-user that is worse than a leak — the first anonymous +// admin to run /newpack would own the result and block every other one. +// +// Other modules check only From != nil && From.ID != 0, which is safe for +// paper-trading state but not for durable Telegram-side objects. +func senderID(msg *models.Message) (int64, error) { + if msg == nil || msg.From == nil || msg.From.ID == 0 { + return 0, errNoPersonalSender + } + if msg.From.IsBot { + return 0, errNoPersonalSender + } + if msg.SenderChat != nil { + return 0, errNoPersonalSender + } + return msg.From.ID, nil +} diff --git a/internal/modules/sticker/sender_test.go b/internal/modules/sticker/sender_test.go new file mode 100644 index 0000000..79ac074 --- /dev/null +++ b/internal/modules/sticker/sender_test.go @@ -0,0 +1,63 @@ +package sticker + +import ( + "strings" + "testing" + + "github.com/go-telegram/bot/models" +) + +// Every pack is keyed by this value, so anything that is not one human must be +// refused before the key is built. +func TestSenderID(t *testing.T) { + cases := []struct { + name string + msg *models.Message + want int64 + ok bool + }{ + {"personal user", &models.Message{From: &models.User{ID: 42}}, 42, true}, + {"nil message", nil, 0, false}, + {"nil from", &models.Message{}, 0, false}, + {"zero id", &models.Message{From: &models.User{ID: 0}}, 0, false}, + {"bot sender", &models.Message{From: &models.User{ID: 7, IsBot: true}}, 0, false}, + { + // Telegram substitutes one global GroupAnonymousBot user for every + // anonymous admin message; without this check they would all share + // a single pack. + "anonymous admin", + &models.Message{From: &models.User{ID: 1087968824, IsBot: true}, SenderChat: &models.Chat{ID: -100}}, + 0, false, + }, + { + // A channel post carries SenderChat with a non-bot From. + "sender chat present", + &models.Message{From: &models.User{ID: 42}, SenderChat: &models.Chat{ID: -100}}, + 0, false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := senderID(tc.msg) + if tc.ok { + if err != nil || got != tc.want { + t.Fatalf("senderID = (%d, %v), want (%d, nil)", got, err, tc.want) + } + return + } + if err == nil { + t.Fatalf("senderID = (%d, nil), want an error", got) + } + }) + } +} + +// Denying without explaining leaves the user with no move; anonymous posting is +// a per-message toggle they can flip immediately. +func TestSenderRefusal_ExplainsTheFix(t *testing.T) { + for _, want := range []string{"personal account", "anonymous"} { + if !strings.Contains(senderRefusal, want) { + t.Errorf("senderRefusal %q does not mention %q", senderRefusal, want) + } + } +} diff --git a/internal/modules/sticker/setname.go b/internal/modules/sticker/setname.go new file mode 100644 index 0000000..170da5b --- /dev/null +++ b/internal/modules/sticker/setname.go @@ -0,0 +1,119 @@ +package sticker + +import ( + "context" + "fmt" + "regexp" + "strings" + "sync" + + "github.com/go-telegram/bot" +) + +const ( + // maxSetNameLen is Telegram's cap on a sticker set's short name. + maxSetNameLen = 64 + // maxTitleLen is Telegram's cap on a set title. + maxTitleLen = 64 + // minSlugLen / maxSlugLen keep the share link readable and leave room for + // the "_by_" suffix inside maxSetNameLen. + minSlugLen = 3 + maxSlugLen = 40 +) + +// slugRe is the user-chosen half of a set name: 3-40 chars, starting with a +// letter. Telegram additionally forbids consecutive underscores, which a +// character class cannot express, so validateSlug checks that separately. +var slugRe = regexp.MustCompile(`^[a-z][a-z0-9_]{2,39}$`) + +// validateSlug reports why a slug is unusable, or nil when it is fine. +// +// The slug is the one irreversible choice in this module: it fixes +// t.me/addstickers/_by_ forever, because Telegram has no +// rename-short-name method. Rejecting loudly here is much cheaper than a user +// discovering the typo is permanent. +func validateSlug(slug string) error { + if !slugRe.MatchString(slug) { + return refuse(fmt.Sprintf("Pack name must be %d-%d characters: lowercase letters, digits and underscores, starting with a letter.", minSlugLen, maxSlugLen)) + } + if strings.Contains(slug, "__") { + return refuse("Pack name cannot contain two underscores in a row.") + } + if strings.HasSuffix(slug, "_") { + return refuse("Pack name cannot end with an underscore.") + } + return nil +} + +// makeSetName builds the Telegram set name for a new pack. It is used only at +// creation — never to resolve ownership, which compares the *stored* name (see +// ownsSet). +// +// The error reports the remaining budget rather than only refusing, because the +// only fix available to the user is a shorter slug and the limit depends on the +// bot's username length, which they cannot see. +func makeSetName(slug, botUsername string) (string, error) { + if botUsername == "" { + return "", errNoUsername + } + suffix := "_by_" + botUsername + if len(slug)+len(suffix) > maxSetNameLen { + budget := maxSetNameLen - len(suffix) + if budget > maxSlugLen { + budget = maxSlugLen + } + return "", refuse(fmt.Sprintf("Pack name is too long for this bot — use at most %d characters.", budget)) + } + return slug + suffix, nil +} + +// ownsSet reports whether setName is the caller's pack, comparing +// case-insensitively against the *stored* Pack.Name. +// +// It deliberately does not re-derive the name from the live bot username. +// Renaming the bot in BotFather is supported and leaves existing set names +// untouched, so a derived comparison would make every user's own pack refuse as +// "not yours" while /mypack still displayed it. Comparing the stored name also +// sidesteps casing: Telegram returns SetName with whatever casing the set was +// created with. +func ownsSet(pack Pack, setName string) bool { + if pack.Name == "" || setName == "" { + return false + } + return strings.EqualFold(pack.Name, setName) +} + +// usernameResolver caches the bot's username for building new set names. +// +// The bot starts with bot.WithSkipGetMe(), so nothing populates a username +// until this asks. Failures are never cached: a transient GetMe error must not +// disable /newpack for the process's lifetime. +type usernameResolver struct { + mu sync.Mutex + username string +} + +// resolve returns the bot's username, calling GetMe at most once per success. +// It takes the handler's *bot.Bot rather than Deps.Bot, which is documented +// nil-safe and is nil under BuildOptions{}. +func (r *usernameResolver) resolve(ctx context.Context, b *bot.Bot) (string, error) { + r.mu.Lock() + cached := r.username + r.mu.Unlock() + if cached != "" { + return cached, nil + } + + me, err := b.GetMe(ctx) + if err != nil { + return "", err + } + if me == nil || me.Username == "" { + return "", errNoUsername + } + + r.mu.Lock() + r.username = me.Username + r.mu.Unlock() + return me.Username, nil +} diff --git a/internal/modules/sticker/setname_test.go b/internal/modules/sticker/setname_test.go new file mode 100644 index 0000000..c0c1769 --- /dev/null +++ b/internal/modules/sticker/setname_test.go @@ -0,0 +1,97 @@ +package sticker + +import ( + "strings" + "testing" +) + +func TestValidateSlug(t *testing.T) { + cases := []struct { + slug string + ok bool + }{ + {"mypack", true}, + {"my_pack_2", true}, + {"abc", true}, + {strings.Repeat("a", maxSlugLen), true}, + {"ab", false}, // too short + {strings.Repeat("a", maxSlugLen+1), false}, // too long + {"1pack", false}, // leading digit + {"My_Pack", false}, // uppercase + {"my__pack", false}, // consecutive underscores + {"mypack_", false}, // trailing underscore + {"my-pack", false}, // hyphen + {"", false}, + } + for _, tc := range cases { + err := validateSlug(tc.slug) + if tc.ok && err != nil { + t.Errorf("validateSlug(%q) = %v, want nil", tc.slug, err) + } + if !tc.ok && err == nil { + t.Errorf("validateSlug(%q) = nil, want an error", tc.slug) + } + } +} + +func TestMakeSetName(t *testing.T) { + got, err := makeSetName("mypack", "miti99bot") + if err != nil { + t.Fatalf("makeSetName: %v", err) + } + if want := "mypack_by_miti99bot"; got != want { + t.Errorf("makeSetName = %q, want %q", got, want) + } +} + +// The set name has a hard 64-char ceiling and the slug is the only part the +// user controls, so the refusal has to name the budget they actually have. +func TestMakeSetName_TooLongReportsBudget(t *testing.T) { + username := strings.Repeat("b", 30) + slug := strings.Repeat("a", maxSlugLen) + _, err := makeSetName(slug, username) + if err == nil { + t.Fatalf("makeSetName(%d-char slug, %d-char username) succeeded; want a refusal", len(slug), len(username)) + } + // 64 - len("_by_" + username) = 30 + if !strings.Contains(err.Error(), "30") { + t.Errorf("refusal %q does not state the remaining budget", err) + } +} + +func TestOwnsSet(t *testing.T) { + pack := Pack{Slug: "mypack", Name: "mypack_by_bot"} + cases := []struct { + setName string + want bool + }{ + {"mypack_by_bot", true}, + {"MyPack_By_Bot", true}, // Telegram echoes the creation casing + {"otherpack_by_bot", false}, + {"mypack_by_otherbot", false}, + {"", false}, + } + for _, tc := range cases { + if got := ownsSet(pack, tc.setName); got != tc.want { + t.Errorf("ownsSet(%q) = %v, want %v", tc.setName, got, tc.want) + } + } + if ownsSet(Pack{}, "anything") { + t.Error("ownsSet with an empty stored name = true, want false") + } +} + +// Renaming the bot in BotFather leaves existing set names untouched. Ownership +// compares the stored name for exactly this reason: deriving it from the live +// username would make every user's own pack refuse as "not yours". +func TestOwnsSet_SurvivesBotRename(t *testing.T) { + pack := Pack{Slug: "mypack", Name: "mypack_by_oldbot"} + if !ownsSet(pack, "mypack_by_oldbot") { + t.Error("pack stopped resolving after the bot was renamed") + } + // The new username only ever builds *new* names. + fresh, err := makeSetName("newpack", "newbot") + if err != nil || fresh != "newpack_by_newbot" { + t.Errorf("makeSetName after rename = (%q, %v)", fresh, err) + } +} diff --git a/internal/modules/sticker/setpackicon.go b/internal/modules/sticker/setpackicon.go new file mode 100644 index 0000000..b6ad2b0 --- /dev/null +++ b/internal/modules/sticker/setpackicon.go @@ -0,0 +1,56 @@ +package sticker + +import ( + "bytes" + "context" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// handleSetPackIcon sets the pack thumbnail from a sticker already in the pack. +// +// The sticker's own file_id cannot simply be handed to Telegram: a pack +// thumbnail must be exactly 100x100, which a 512px sticker is not. So the image +// is fetched, resized, and uploaded as a new file. +func (s *state) handleSetPackIcon(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + owned, err := s.resolveOwned(ctx, msg, ownerID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_setpackicon_resolve", err) + } + + // Reserve the reply tail before the slow leg. See mediaContext. + mediaCtx, cancelMedia := mediaContext(ctx) + defer cancelMedia() + + raw, err := downloadFile(mediaCtx, b, owned.fileID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_setpackicon_download", err) + } + thumb, err := toThumbnailPNG(raw) + if err != nil { + return replyErr(ctx, b, msg, "sticker_setpackicon_resize", err) + } + + if _, err := b.SetStickerSetThumbnail(ctx, &bot.SetStickerSetThumbnailParams{ + Name: owned.pack.Name, + UserID: ownerID, + Thumbnail: &models.InputFileUpload{Filename: "thumb.png", Data: bytes.NewReader(thumb)}, + Format: stickerFormatStatic, + }); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_setpackicon", err) + } + return reply(ctx, b, msg, "Pack icon updated.") +} diff --git a/internal/modules/sticker/state.go b/internal/modules/sticker/state.go new file mode 100644 index 0000000..eb1901e --- /dev/null +++ b/internal/modules/sticker/state.go @@ -0,0 +1,127 @@ +package sticker + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/keylock" + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +const ( + // handlerTimeout bounds every handler in this module. + // + // Nothing else does. The bot registers handlers with + // bot.WithNotAsyncHandlers() and one worker, so updates run inline on the + // polling goroutine, and the handler context is rootCtx, which carries no + // deadline — the only remaining ceiling is the library's shared 60s HTTP + // client, per call. A handler making several sequential API calls could + // therefore freeze the bot for every user for minutes. + handlerTimeout = 10 * time.Second + + // commitTimeout bounds a post-success store write. These run on a context + // detached from the request (see commitContext), so they need their own. + commitTimeout = 5 * time.Second +) + +// state holds everything the handlers share. Mirrors the shape used by coin +// and stock: a typed store, a second typed view for pending actions, the +// per-user lock map, and an injectable clock. +type state struct { + store PackStore + pending PendingDeleteStore + slugs SlugStore + resolver usernameResolver + locks keylock.Map + nowFn func() time.Time +} + +func (s *state) now() time.Time { + if s.nowFn != nil { + return s.nowFn() + } + return time.Now().UTC() +} + +// commitContext detaches a store write from the request context. +// +// rootCtx is cancelled by SIGTERM, and a handler is most likely to be mid-flight +// exactly when a deploy lands. A commit that records a completed Telegram-side +// action must not be lost because the process is shutting down: at that point +// the set already exists and only the bot's memory of it is at stake. +func commitContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), commitTimeout) +} + +// handlerContext applies the module-wide deadline. +func handlerContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, handlerTimeout) +} + +// mediaContext bounds the download-and-upload leg of a handler, reserving the +// tail of the parent's budget for what comes after it. +// +// This module is the only one that spends most of its deadline before it has +// anything to say: a photo /newpack downloads, resamples and re-uploads before +// it calls CreateNewStickerSet. Run on the bare handler context, a slow link +// exhausted the whole 10s inside the media leg, and the reply — including the +// error reply explaining what went wrong — was then sent on a dead context, so +// the user saw nothing at all. chathelper.FetchContext is the existing fix for +// exactly this, already used by coin, gold, stock and monkeyd. +func mediaContext(ctx context.Context) (context.Context, context.CancelFunc) { + return chathelper.FetchContext(ctx) +} + +// lockUser serialises a user's mutations. +// +// Nothing contends for it today: the map is state-local to this module, the bot +// dispatches inline with a single worker, and this module registers neither a +// cron nor a command hook. An earlier version of this comment claimed the cron +// scheduler and stats hook contended here — they do not, and a wrong reason for +// a right guard is worse than none, because the next reader trusts it. +// +// It stays because every mutation here is a read-modify-write, which is wrong +// the moment dispatch stops being serial, and an uncontended mutex costs +// nothing. Note that releaseSlug's read-then-delete is not atomic under this +// lock either; that is safe only while dispatch is serial. +func (s *state) lockUser(ownerID int64) func() { + return s.locks.Acquire(strconv.FormatInt(ownerID, 10)) +} + +// commandArgs returns the whitespace-separated arguments after the command. +func commandArgs(msg *models.Message) []string { + return strings.Fields(chathelper.ArgAfterCommand(msg.Text)) +} + +// commandArgText returns the raw text after the command, trimmed. +func commandArgText(msg *models.Message) string { + return chathelper.ArgAfterCommand(msg.Text) +} + +// reply sends text as a reply to msg. +func reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error { + return chathelper.Reply(ctx, b, msg, text) +} + +// replyErr turns a handler error into a reply. +// +// A userError is shown verbatim — it was written for the user. Anything else is +// logged and replaced with a generic line: internal errors can carry a download +// URL with the bot token in it, and this module must never echo one. +func replyErr(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error { + var ue userError + if errors.As(err, &ue) { + return reply(ctx, b, msg, ue.msg) + } + log.Error(op, "err", err) + return reply(ctx, b, msg, genericFailure) +} + +const genericFailure = "Something went wrong. Try again in a moment." diff --git a/internal/modules/sticker/sticker.go b/internal/modules/sticker/sticker.go new file mode 100644 index 0000000..e6afab7 --- /dev/null +++ b/internal/modules/sticker/sticker.go @@ -0,0 +1,89 @@ +package sticker + +import ( + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" +) + +// New is the sticker-packs module factory. +// +// Commands are unprefixed so they match the names @Stickers uses: the registry +// keys commands by Command.Name independent of module name, which is how misc +// ships /ff. Three typed views share one collection, with disjoint key spaces: +// Pack records keyed by owner ID, name reservations under "slug:", and pending +// deletes under "pending-delete:". +func New(deps modules.Deps) modules.Module { + s := &state{ + store: storage.Typed[Pack](deps.Store), + pending: storage.Typed[PendingDelete](deps.Store), + slugs: storage.Typed[SlugReservation](deps.Store), + } + return modules.Module{ + Commands: []modules.Command{ + { + Name: "newpack", + Visibility: modules.VisibilityPublic, + Description: "Create your sticker pack from a replied sticker", + Parameters: " ", + Handler: s.handleNewPack, + }, + { + Name: "mypack", + Visibility: modules.VisibilityPublic, + Description: "Show your sticker pack and its link", + Handler: s.handleMyPack, + }, + { + Name: "addsticker", + Visibility: modules.VisibilityPublic, + Description: "Add the replied sticker to your pack", + Parameters: "[emoji...]", + Handler: s.handleAddSticker, + }, + { + Name: "delsticker", + Visibility: modules.VisibilityPublic, + Description: "Remove the replied sticker from your pack", + Handler: s.handleDelSticker, + }, + { + Name: "editsticker", + Visibility: modules.VisibilityPublic, + Description: "Change the emoji of a sticker in your pack", + Parameters: "", + Handler: s.handleEditSticker, + }, + { + Name: "ordersticker", + Visibility: modules.VisibilityPublic, + Description: "Move a sticker in your pack to a position", + Parameters: "", + Handler: s.handleOrderSticker, + }, + { + Name: "setpackicon", + Visibility: modules.VisibilityPublic, + Description: "Set your pack's icon from a sticker in it", + Handler: s.handleSetPackIcon, + }, + { + Name: "renamepack", + Visibility: modules.VisibilityPublic, + Description: "Change your pack's title (the link cannot change)", + Parameters: "", + Handler: s.handleRenamePack, + }, + { + Name: "delpack", + Visibility: modules.VisibilityPublic, + Description: "Delete your pack after confirmation", + Handler: s.handleDelPack, + }, + }, + Callbacks: []modules.Callback{{ + Prefix: callbackPrefix, + Visibility: modules.VisibilityPublic, + Handler: s.handleDelPackCallback, + }}, + } +} diff --git a/internal/modules/sticker/sticker_handlers.go b/internal/modules/sticker/sticker_handlers.go new file mode 100644 index 0000000..4dab023 --- /dev/null +++ b/internal/modules/sticker/sticker_handlers.go @@ -0,0 +1,224 @@ +package sticker + +import ( + "context" + "fmt" + "strconv" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" +) + +// maxStickersPerPack is Telegram's documented ceiling for a regular set. It is +// not enforced locally — the server is the authority and a local copy would go +// stale — but it is quoted back to the user when the server refuses. +const maxStickersPerPack = 120 + +// handleAddSticker adds the replied sticker (or, from Phase 5, photo) to the +// caller's pack. +func (s *state) handleAddSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + // Every argument is an emoji: with no pack token to disambiguate, a stray + // word fails here rather than being silently read as something else. + emoji, err := parseEmoji(commandArgs(msg)) + if err != nil { + return replyErr(ctx, b, msg, "sticker_addsticker_emoji", err) + } + + pack, found, err := getPack(ctx, s.store, ownerID) + if err != nil { + log.Error("sticker_addsticker_load", "err", err) + return reply(ctx, b, msg, genericFailure) + } + // Unlike resolveOwned's deliberately uniform refusal, this one is specific: + // it answers only "do *you* have a pack", about the caller's own state, and + // so discloses nothing about anyone else. + if !found { + return reply(ctx, b, msg, noPackYet) + } + if pack.Pending { + return reply(ctx, b, msg, noPackYet+pendingMarker) + } + + source, err := s.resolveSource(ctx, b, ownerID, msg) + if err != nil { + return replyErr(ctx, b, msg, "sticker_addsticker_source", err) + } + + // Precedence: explicit args, then the replied sticker's own emoji, then the + // default. Telegram requires at least one. + if len(emoji) == 0 { + emoji = source.emoji + } + if len(emoji) == 0 { + emoji = []string{defaultEmoji} + } + + defer s.lockUser(ownerID)() + + _, err = b.AddStickerToSet(ctx, &bot.AddStickerToSetParams{ + UserID: ownerID, // always the caller: a non-owner never reaches this call + Name: pack.Name, + Sticker: models.InputSticker{ + Sticker: source.fileID, + Format: stickerFormatStatic, + EmojiList: emoji, + }, + }) + if err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_addsticker", err) + } + + updated, err := s.adjustCount(ctx, ownerID, +1) + if err != nil { + // The sticker is already in the set; only our count is stale. + log.Error("sticker_addsticker_commit", "err", err) + updated = pack + updated.Count++ + } + return reply(ctx, b, msg, fmt.Sprintf("Added to %s (%d stickers).\n%s", + updated.Title, updated.Count, shareLink(updated.Name))) +} + +// handleDelSticker removes the replied sticker from the caller's pack. +// +// It deliberately does *not* probe afterwards to see whether the set survived. +// Whether removing the last sticker also destroys the set is undocumented, and +// an earlier design that probed would have deleted the pack record whenever the +// probe merely failed — so a 429, a DNS blip, or a SIGTERM during a routine +// delete would erase the only record of a live pack. Deleting the record needs +// a positive signal, and the next command's STICKERSET_INVALID is one. +func (s *state) handleDelSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + owned, err := s.resolveOwned(ctx, msg, ownerID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_delsticker_resolve", err) + } + + // Same read-modify-write on Count as /addsticker, so the same lock. + defer s.lockUser(ownerID)() + + if _, err := b.DeleteStickerFromSet(ctx, &bot.DeleteStickerFromSetParams{Sticker: owned.fileID}); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_delsticker", err) + } + + pack, err := s.adjustCount(ctx, ownerID, -1) + if err != nil { + // The sticker is already gone from the set; only our count is stale. + log.Error("sticker_delsticker_commit", "err", err) + pack = owned.pack + if pack.Count > 0 { + pack.Count-- + } + } + + if pack.Count == 0 { + // Telegram may have removed the now-empty set. /mypack makes no API + // calls so it cannot notice, and /newpack stays blocked while a record + // exists — so name the command that clears it. + return reply(ctx, b, msg, "Removed. Your pack is now empty, and Telegram may have deleted it. "+ + "If /addsticker says the pack is gone, use /delpack to clear it and /newpack to start again.") + } + return reply(ctx, b, msg, fmt.Sprintf("Removed. %s now has %d sticker(s).", pack.Title, pack.Count)) +} + +// handleEditSticker replaces the emoji of a sticker in the caller's pack. +func (s *state) handleEditSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + emoji, err := parseEmoji(commandArgs(msg)) + if err != nil { + return replyErr(ctx, b, msg, "sticker_editsticker_emoji", err) + } + // An empty emoji_list is invalid, so this cannot fall back to a default the + // way /addsticker does: the user has to say what they want. + if len(emoji) == 0 { + return reply(ctx, b, msg, "Usage: /editsticker \nReply to a sticker in your pack with at least one emoji.") + } + + owned, err := s.resolveOwned(ctx, msg, ownerID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_editsticker_resolve", err) + } + + if _, err := b.SetStickerEmojiList(ctx, &bot.SetStickerEmojiListParams{ + Sticker: owned.fileID, + EmojiList: emoji, + }); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_editsticker", err) + } + return reply(ctx, b, msg, "Emoji updated.") +} + +// handleOrderSticker moves a sticker to a new position in the caller's pack. +func (s *state) handleOrderSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + args := commandArgs(msg) + if len(args) != 1 { + return reply(ctx, b, msg, "Usage: /ordersticker \nReply to a sticker in your pack. Positions start at 0.") + } + pos, err := strconv.Atoi(args[0]) + if err != nil || pos < 0 { + // Only the lower bound is checked locally. The upper bound is the set's + // current size, which Telegram knows and a local copy would not. + return reply(ctx, b, msg, "Position must be a whole number, 0 or greater.") + } + + owned, err := s.resolveOwned(ctx, msg, ownerID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_ordersticker_resolve", err) + } + + if _, err := b.SetStickerPositionInSet(ctx, &bot.SetStickerPositionInSetParams{ + Sticker: owned.fileID, + Position: pos, + }); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_ordersticker", err) + } + return reply(ctx, b, msg, fmt.Sprintf("Moved to position %d.", pos)) +} diff --git a/internal/modules/wordle/lookup_test.go b/internal/modules/wordle/lookup_test.go index c09a28d..75a0653 100644 --- a/internal/modules/wordle/lookup_test.go +++ b/internal/modules/wordle/lookup_test.go @@ -4,14 +4,14 @@ import "testing" func TestNormalizeWord(t *testing.T) { cases := map[string]string{ - "": "", - "crane": "crane", - "CRANE": "crane", - " crane ": "crane", - "c-r-a-n-e": "crane", - "héllo": "hllo", // strips non a-z (including the é and accented o-equivalent) - "!@#$%": "", - "42 crane": "crane", + "": "", + "crane": "crane", + "CRANE": "crane", + " crane ": "crane", + "c-r-a-n-e": "crane", + "héllo": "hllo", // strips non a-z (including the é and accented o-equivalent) + "!@#$%": "", + "42 crane": "crane", } for in, want := range cases { if got := normalizeWord(in); got != want { diff --git a/internal/testutil/recording_bot.go b/internal/testutil/recording_bot.go index b7f2be3..f4767bd 100644 --- a/internal/testutil/recording_bot.go +++ b/internal/testutil/recording_bot.go @@ -1,8 +1,10 @@ package testutil import ( + "bytes" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -39,6 +41,7 @@ type RecordingBot struct { mu sync.Mutex calls []SentCall failures map[string]failureResponse + stubs map[string]string nextMessageID int } @@ -87,15 +90,66 @@ func (rb *RecordingBot) LastSent() SentCall { return rb.calls[len(rb.calls)-1] } -// Reset drops all captured calls. Useful between sub-tests sharing one bot. +// Reset drops all captured calls. +// +// It deliberately does NOT clear registered failures or stubs — those are setup, +// not observations. A sub-test that needs different responses should register +// them explicitly or build its own bot. func (rb *RecordingBot) Reset() { rb.mu.Lock() rb.calls = nil rb.mu.Unlock() } +// StubMethod makes method return resultJSON as the "result" field of an ok +// response, so methods that decode into a struct can be exercised at all. +// +// Without a stub, okResponseFor answers every non-message-producing method with +// `{"ok":true,"result":true}` — which getStickerSet, getFile, uploadStickerFile, +// and getMe cannot decode, so under the bare harness they can only ever return +// "json: cannot unmarshal bool" errors. +// +// resultJSON is the raw JSON value for "result" — an object, array, or scalar: +// +// rb.StubMethod("getStickerSet", `{"name":"p_by_bot","title":"P","sticker_type":"regular","stickers":[]}`) +// +// A failure registered for the same method wins, so a test can override a +// stubbed happy path without unregistering the stub. +func (rb *RecordingBot) StubMethod(method string, resultJSON string) { + rb.mu.Lock() + defer rb.mu.Unlock() + if rb.stubs == nil { + rb.stubs = map[string]string{} + } + rb.stubs[method] = resultJSON +} + +// FailMethodCode makes method fail with a Telegram-shaped error carrying an +// error_code, so the library maps it to the same sentinel production emits +// (bot.ErrorBadRequest for 400, bot.ErrorForbidden for 403, and so on). +// +// This is the difference from FailMethod: the library switches on the +// error_code *in the response body* (raw_request.go:103-125), not the HTTP +// status, so a codeless failure never takes a sentinel shape. Handlers that +// classify errors with errors.Is must be tested through this method. +// +// rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERS_TOO_MUCH") +func (rb *RecordingBot) FailMethodCode(method string, errorCode int, description string) { + body, _ := json.Marshal(map[string]any{ + "ok": false, + "error_code": errorCode, + "description": description, + }) + rb.FailMethod(method, errorCode, string(body)) +} + // FailMethod makes the recording server return a Telegram API error for a // specific method while still recording the attempted call. +// +// The body is emitted verbatim, so unless it carries an "error_code" field the +// resulting error is **codeless**: the library returns a generic decode/status +// error rather than bot.ErrorBadRequest or any other sentinel. Use +// FailMethodCode when the test asserts on the error's classification. func (rb *RecordingBot) FailMethod(method string, status int, body string) { rb.mu.Lock() defer rb.mu.Unlock() @@ -118,22 +172,51 @@ func (rb *RecordingBot) FailMethod(method string, status int, body string) { func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) { method := apiMethodFromPath(r.URL.Path) - // 8 MiB cap — well above any realistic test payload but bounded for gosec. - // #nosec G120 — explicit upper bound above - if err := r.ParseMultipartForm(8 << 20); err != nil { - http.Error(w, "bad form", http.StatusBadRequest) + // Parameterless methods (getMe) send no body at all, so a parse failure is + // not an error there — it just means there are no form fields to record. + // Failing the request would make those methods untestable no matter what + // the test registered. + // + // That tolerance is scoped to requests that carry no multipart body. A + // request that claims to be multipart and then fails to parse is a real + // fault, and answering it 200 with an empty Form would quietly satisfy + // every test that asserts a field is *absent*. + // Read the body before parsing, because an empty body and a corrupt one are + // otherwise indistinguishable: multipart reports both as "no parts". + // + // The distinction matters. Parameterless methods (getMe) genuinely send no + // body, and rejecting them would make those methods untestable. A body that + // is present but unparseable is a real fault, and answering it 200 with an + // empty form would quietly satisfy every test that asserts a field is + // *absent* — several outside this package do exactly that. + // + // 8 MiB cap: well above any realistic test payload, bounded for gosec. + const maxBody = 8 << 20 + body, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) return } - form := make(map[string]string, len(r.MultipartForm.Value)) - for k, vs := range r.MultipartForm.Value { - if len(vs) > 0 { - form[k] = vs[0] + + form := map[string]string{} + if len(body) > 0 { + r.Body = io.NopCloser(bytes.NewReader(body)) + // #nosec G120 — bounded by maxBody above + if err := r.ParseMultipartForm(maxBody); err != nil { + http.Error(w, "bad multipart form: "+err.Error(), http.StatusBadRequest) + return + } + for k, vs := range r.MultipartForm.Value { + if len(vs) > 0 { + form[k] = vs[0] + } } } rb.mu.Lock() rb.calls = append(rb.calls, SentCall{Method: method, Form: form}) failure, shouldFail := rb.failures[method] + stub, hasStub := rb.stubs[method] messageID := rb.nextMessageID if !shouldFail && isMessageProducingMethod(method) { rb.nextMessageID++ @@ -142,11 +225,16 @@ func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) { rb.mu.Unlock() w.Header().Set("Content-Type", "application/json") + // Failures win over stubs so a test can override a stubbed happy path. if shouldFail { w.WriteHeader(failure.status) _, _ = w.Write([]byte(failure.body)) return } + if hasStub { + _, _ = w.Write([]byte(`{"ok":true,"result":` + stub + `}`)) + return + } _, _ = w.Write([]byte(okResponseFor(method, messageID))) } diff --git a/internal/testutil/recording_bot_test.go b/internal/testutil/recording_bot_test.go index 26ff458..88671cd 100644 --- a/internal/testutil/recording_bot_test.go +++ b/internal/testutil/recording_bot_test.go @@ -2,6 +2,9 @@ package testutil import ( "context" + "errors" + "net/http" + "strings" "testing" "github.com/go-telegram/bot" @@ -76,3 +79,105 @@ func TestUpdateBuilders_BotCommandEntity(t *testing.T) { } } } + +// StubMethod exists because the bare harness answers every non-message method +// with `{"ok":true,"result":true}`, which any struct-decoding method rejects. +// Without it, getStickerSet/getFile/uploadStickerFile/getMe can only error. +func TestRecordingBot_StubMethodDecodesIntoStruct(t *testing.T) { + rb := NewRecordingBot(t) + rb.StubMethod("getStickerSet", `{"name":"pack_by_bot","title":"My Pack","sticker_type":"regular","stickers":[{"file_id":"f1","file_unique_id":"u1","type":"regular"}]}`) + + set, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "pack_by_bot"}) + if err != nil { + t.Fatalf("GetStickerSet: %v", err) + } + if set.Name != "pack_by_bot" || set.Title != "My Pack" { + t.Errorf("set = %+v, want name pack_by_bot / title My Pack", set) + } + if len(set.Stickers) != 1 || set.Stickers[0].FileID != "f1" { + t.Errorf("stickers = %+v, want one sticker f1", set.Stickers) + } +} + +// A registered failure must beat a registered stub, so a test can override a +// stubbed happy path without unregistering it. +func TestRecordingBot_FailureWinsOverStub(t *testing.T) { + rb := NewRecordingBot(t) + rb.StubMethod("getStickerSet", `{"name":"pack_by_bot"}`) + rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID") + + if _, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "pack_by_bot"}); err == nil { + t.Fatal("GetStickerSet succeeded; want the registered failure to win over the stub") + } +} + +// The library classifies errors by the error_code in the response body, not the +// HTTP status (raw_request.go), so only a coded failure produces the sentinel +// that production error handling matches on. +func TestRecordingBot_FailMethodCodeYieldsSentinel(t *testing.T) { + rb := NewRecordingBot(t) + rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID") + + _, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "gone_by_bot"}) + if err == nil { + t.Fatal("GetStickerSet succeeded; want an error") + } + if !errors.Is(err, bot.ErrorBadRequest) { + t.Errorf("err = %v, want errors.Is(err, bot.ErrorBadRequest)", err) + } + if !strings.Contains(err.Error(), "STICKERSET_INVALID") { + t.Errorf("err = %v, want it to carry the MTProto code", err) + } +} + +// The contrast that FailMethod's doc comment promises: a codeless failure does +// NOT take the sentinel shape. Handlers that classify with errors.Is must be +// tested through FailMethodCode instead. +func TestRecordingBot_FailMethodIsCodeless(t *testing.T) { + rb := NewRecordingBot(t) + rb.FailMethod("getStickerSet", 400, `{"ok":false,"description":"Bad Request: STICKERSET_INVALID"}`) + + _, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "gone_by_bot"}) + if err == nil { + t.Fatal("GetStickerSet succeeded; want an error") + } + if errors.Is(err, bot.ErrorBadRequest) { + t.Errorf("err = %v is bot.ErrorBadRequest; a codeless failure must not classify", err) + } +} + +// A malformed multipart body must not be answered 200 with an empty form. +// +// The harness tolerates a parse failure only for parameterless calls, which +// send no parts at all. Widening that to every parse failure would make the +// server answer a corrupt request as though it carried no fields — quietly +// satisfying any test that asserts a field is absent. +func TestRecordingBot_RejectsMalformedMultipart(t *testing.T) { + rb := NewRecordingBot(t) + + resp, err := http.Post(rb.Server.URL+"/bottest-token/sendMessage", + "multipart/form-data; boundary=zzz", strings.NewReader("not a multipart body at all")) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d for a corrupt multipart body", resp.StatusCode, http.StatusBadRequest) + } +} + +// A parameterless call sends a multipart content type with no parts, and must +// still be served — several API methods take no arguments. +func TestRecordingBot_ServesParameterlessCall(t *testing.T) { + rb := NewRecordingBot(t) + rb.StubMethod("getMe", `{"id":7,"is_bot":true,"first_name":"T","username":"testbot"}`) + + me, err := rb.Bot.GetMe(context.Background()) + if err != nil { + t.Fatalf("GetMe: %v", err) + } + if me.Username != "testbot" { + t.Errorf("username = %q, want testbot", me.Username) + } +} diff --git a/plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md b/plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md new file mode 100644 index 0000000..9123d2d --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md @@ -0,0 +1,160 @@ +--- +phase: 1 +title: "Phase 1: Shared prerequisites" +status: done +priority: P1 +effort: "4h" +dependencies: [] +--- + +# Phase 1: Shared prerequisites + +## Overview + +Two gaps in shared code that the sticker module would otherwise expose. Both live outside +`internal/modules/sticker` and both block every later phase, so they land first and merge +independently of the feature. + +1. **No panic barrier on the update path** (plan C9) — a handler panic terminates the + process, and Phase 5 proposes decoding attacker-supplied images in a handler. +2. **`RecordingBot` cannot return structured API results** (plan C11) — three later phases + have success criteria that are unimplementable against the current harness. + +## Requirements + +- Functional: a panic in any command or callback handler is recovered, logged with the same + context as a handler error, and does not terminate the process. +- Functional: tests can make any Bot API method return a chosen JSON result, and can make a + method fail with a Telegram-shaped error carrying an `error_code`. +- Non-functional: no behaviour change for existing modules; every existing test passes + unmodified. +- Non-functional: the recovery path must not swallow the failure silently — it increments an + error metric and logs at ERROR. + +## Architecture + +### Panic barrier in `modules.Install` + +`internal/modules/dispatcher.go:65-111` registers two closures per module — one for commands, +one for callback data. Neither recovers. With `bot.WithNotAsyncHandlers()` (plan C1) the +handler runs inline on the single polling goroutine, so an unrecovered panic ends the +process for every user. + +The repo already has the exact pattern to copy at `internal/cron/scheduler.go:66-74`, which +recovers around cron handlers. Mirror it: + +```go +defer func() { + if rec := recover(); rec != nil { + metrics.IncError("handler-panic") + log.Error("command panic", "command", cmdCopy.Name, "recovered", rec, + "stack", string(debug.Stack())) + } +}() +``` + +Apply to both closures. The callback variant should also attempt `AnswerCallbackQuery` so +the user's client stops showing a spinner, guarded so a failure there cannot panic again. + +**Also fix the stale comment at `internal/modules/dispatcher.go:167`,** which claims +"would panic the goroutine before our `recover()` in webhook.go". +`internal/telegram/webhook.go` contains only `DeleteWebhook` — there has been no +webhook-served handler since the move to long polling. The comment currently tells a reader +a protection exists that does not. + +### `RecordingBot` structured responses + +`internal/testutil/recording_bot.go:178-195` (`okResponseFor`) returns +`{"ok":true,"result":true}` for every method not in `isMessageProducingMethod` +(`:156-165`). Methods decoding into a struct therefore always error: + +| Method | Decodes into | Current test behaviour | +|---|---|---| +| `getStickerSet` | `*models.StickerSet` | `json: cannot unmarshal bool` | +| `getFile` | `*models.File` | same | +| `uploadStickerFile` | `*models.File` | same | +| `getMe` | `*models.User` | same | + +Add two capabilities, both additive: + +```go +// StubMethod makes method return the given JSON as its "result" field. +func (rb *RecordingBot) StubMethod(method string, resultJSON string) + +// FailMethodCode makes method fail with a Telegram-shaped error carrying an +// error_code, so library errors take the same ErrorBadRequest / ErrorForbidden +// shape production emits. +func (rb *RecordingBot) FailMethodCode(method string, errorCode int, description string) +``` + +`FailMethod` (`:99-112`) stays as-is for existing callers, but its doc comment must state +that it produces a **codeless** failure that does **not** take the `ErrorBadRequest` shape — +that distinction is what plan rule 4 depends on, and a future reader must not confuse the two. + +Precedence when both a stub and a failure are registered for one method: the failure wins, +so a test can override a stubbed happy path without unregistering it. + +### Why this is a separate phase + +Both changes touch files every other module's tests depend on +(`internal/modules/dispatcher.go`, `internal/testutil/recording_bot.go`). Landing them +alone, with the full suite green, keeps the blast radius reviewable and means a problem here +is not entangled with sticker logic. + +## Related Code Files + +- Modify: `internal/modules/dispatcher.go` (recover in both closures; fix the stale comment) +- Modify: `internal/testutil/recording_bot.go` (`StubMethod`, `FailMethodCode`, doc fix) +- Create: `internal/modules/dispatcher_panic_test.go` +- Modify: `internal/testutil/recording_bot_test.go` +- Reference: `internal/cron/scheduler.go:66-74` (the pattern to mirror) +- Reference: `internal/metrics` (`IncError`), `internal/log` (`Error`) + +## Implementation Steps + +1. Add the recover to the command closure in `Install`, with metric + structured log. +2. Add the recover to the callback closure, including a guarded `AnswerCallbackQuery`. +3. Correct the stale comment at `dispatcher.go:167`. +4. Add `StubMethod` and `FailMethodCode` to `RecordingBot`; document `FailMethod`'s codeless + shape. +5. Tests per the Todo list. +6. Run the full suite — every existing test must pass untouched. + +## Todo + +- [x] `recover()` in the command closure with `metrics.IncError("handler-panic")` +- [x] `recover()` in the callback closure with guarded `AnswerCallbackQuery` +- [x] Fix the stale `recover()` comment at `dispatcher.go:167` +- [x] `RecordingBot.StubMethod(method, resultJSON)` +- [x] `RecordingBot.FailMethodCode(method, errorCode, description)` +- [x] Document that `FailMethod` produces a codeless failure +- [x] `dispatcher_panic_test.go`: panicking command handler +- [x] `dispatcher_panic_test.go`: panicking callback handler +- [x] `recording_bot_test.go`: stubbed `getStickerSet` decodes into `models.StickerSet` +- [x] `recording_bot_test.go`: `FailMethodCode` yields `bot.ErrorBadRequest` + +## Success Criteria + +- [x] A command handler that panics is recovered; the test process survives and the error metric increments +- [x] A callback handler that panics is recovered and the callback query is still answered +- [x] `rg "recover\(\)" internal/modules/dispatcher.go` returns two hits +- [x] No comment in the repo claims a `recover()` exists in `webhook.go` +- [x] `rb.StubMethod("getStickerSet", ...)` lets `b.GetStickerSet` return a populated `*models.StickerSet` with a nil error +- [x] `rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")` produces an error satisfying `errors.Is(err, bot.ErrorBadRequest)` +- [x] `go test ./...` passes with no changes to any existing test file other than additions + +## Risk Assessment + +**Recovering a panic can mask a real bug.** A handler that panics on every invocation would +now fail quietly per-request instead of crashing loudly. Mitigation: log at ERROR with the +full stack and increment a distinct `handler-panic` metric, so the condition is visible +rather than silent. This is the same trade the cron scheduler already made +(`cron/scheduler.go:66-74`); consistency with it is worth more than a second opinion here. + +**Changing shared test infrastructure can break other modules' tests.** Both additions are +new methods; no existing signature or default behaviour changes. The success criterion +"no changes to any existing test file other than additions" is what proves it. + +**Scope note.** The panic barrier is a pre-existing repo-wide gap, not one this module +introduces — the module only makes it far easier to reach. It is included here on an +explicit user decision rather than as silent scope expansion. diff --git a/plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md b/plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md new file mode 100644 index 0000000..93b4335 --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md @@ -0,0 +1,188 @@ +--- +phase: 2 +title: "Phase 2: Store, set names, emoji parsing" +status: done +priority: P1 +effort: "3h" +dependencies: [1] +--- + +# Phase 2: Store, set names, emoji parsing + +## Overview + +Pure, Telegram-free foundation: the persisted pack record, the mapping between a +user-chosen slug and a Telegram set name, sender validation, and emoji-argument parsing. +Every function here is unit-testable without a bot. + +One pack per user means the store layer is a single keyed record, not a collection scan. + +## Requirements + +- Functional: persist at most one pack record per user, keyed so the caller's own user ID + is the whole key — making the lookup itself the ownership check. +- Functional: construct a Telegram set name `_by_` at creation, and match + a sticker's `set_name` against the stored pack **without** re-deriving it from the live + username. +- Functional: reject senders that are bots or anonymous chat surrogates. +- Functional: split an emoji argument run into individual emoji, accepting `😂 🔥` and `😂🔥`. +- Non-functional: no network calls and no Telegram API types in the store layer. + +## Architecture + +### Pack record + +```go +// Pack is the single bot-created sticker set owned by a Telegram user. +type Pack struct { + Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL + Name string `bson:"name"` // Telegram set name, "_by_" + Title string `bson:"title"` // display title, mutable + OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to + Count int `bson:"count"` // stickers in the set; keeps /mypack API-free + Pending bool `bson:"pending"` // write-ahead intent; see Phase 3 + CreatedAt int64 `bson:"createdAt"` // unix millis +} +``` + +- **Key: `strconv.FormatInt(ownerID, 10)`** — the user ID alone. One pack per user makes the + slug unnecessary as a key component, which removes the prefix scan entirely. +- `getPack(ctx, ownerID) (Pack, bool, error)` — one `Get`; `storage.ErrNotFound` maps to + `false, nil`. There is no `listPacks` and no `List` call anywhere in the module. + + This retires the worst red-team finding. The previous `listPacks` was a structural N+1 + (`mongo_doc_store.go:157-165` projects `_id` only, forcing a `Get` per key), and + `/packlist` layered ten `GetStickerSet` calls on top of it under a 60s-per-call ceiling. + +- `Count` keeps `/mypack` free of API calls. It is **advisory** — a user editing the pack + through @Stickers desyncs it. Phase 3 refreshes it opportunistically. +- `Pending` implements write-ahead intent (plan C5). Phase 3 owns the state machine. +- `Pack`'s bson tags must not collide with `_id` / `version` / `updatedAt`; `storage.Typed` + panics on collision (`internal/storage/doc_store.go:72`). + +### Sender validation + +```go +// senderID returns the personal Telegram user behind msg, or an error when the +// message has no usable personal identity. +func senderID(msg *models.Message) (int64, error) +``` + +Rejects, in order: nil `msg`/`From`, zero ID, `From.IsBot`, and non-nil `msg.SenderChat`. + +The last two are not theoretical. Telegram substitutes a single global `GroupAnonymousBot` +user for **every** anonymous group-admin message and puts the real origin in `SenderChat` +(`models/message.go:86-87`). Without this check, all anonymous admins across all groups would +share one pack — and under one-pack-per-user that is worse than it was before, because the +first anonymous admin to run `/newpack` would block every other one and own the result. +`rg "IsBot" internal/` returns zero hits today; `coin`, `gold`, and `stock` check only +`From != nil && From.ID != 0`, which is safe for paper-trading state but not for durable +Telegram objects. + +The refusal must explain the fix ("sticker packs need a personal account — turn off anonymous +posting for this message"), not just deny. + +### Set names + +- `slugRe = ^[a-z][a-z0-9_]{2,39}$` — 3 to 40 chars. Additionally reject `__` (Telegram + forbids consecutive underscores) and a trailing `_`. The cap is for link readability and to + stay inside the 64-char set-name budget. +- `makeSetName(slug, botUsername) (string, error)` → `slug + "_by_" + botUsername`, erroring + above 64 chars and reporting the remaining slug budget so the reply can say "max N + characters". +- **`ownsSet(pack Pack, setName string) bool`** — case-insensitive comparison of `setName` + against the stored `pack.Name`. This is the ownership resolver used by Phase 4. + + It deliberately replaces a `parseSlug(setName, botUsername)` design that re-derived the slug + from the *live* username and discarded the persisted `Pack.Name`. Renaming the bot in + BotFather — supported, and it leaves existing set names untouched — would have made every + user's own pack refuse as "not yours" while `/mypack` still displayed it. Comparing the + stored name also removes a case-sensitivity trap, since Telegram returns `SetName` with + whatever casing the set was created with (plan R8). + +- `usernameResolver` caches `GetMe` and **must not cache failures**. The bot starts with + `bot.WithSkipGetMe()` (`internal/telegram/client.go:26`), so nothing populates a username + until the module asks. It is used **only** by `/newpack` to name a new set — never for + ownership. It takes the handler's `b *bot.Bot`, **not** `deps.Bot`, which is documented + nil-safe (`internal/modules/module.go:88`) and is nil under `BuildOptions{}` + (`cmd/server/command_menu_test.go:55`). + +### Emoji parsing + +`parseEmoji(args []string) ([]string, error)` — join arguments, then split into clusters: + +- keep ZWJ (`U+200D`) sequences together; +- absorb variation selectors (`U+FE0F`/`U+FE0E`), skin-tone modifiers (`U+1F3FB`–`U+1F3FF`), + and combining marks into the preceding cluster; +- pair regional indicators (`U+1F1E6`–`U+1F1FF`); +- keep keycap sequences (` U+FE0F U+20E3`) together. + +Reject non-emoji text with a usage error. Cap at 20 (`emoji_list` is documented 1–20; the +server's own message is the literal `too many emoji specified`). `defaultEmoji = "⭐"`. + +Because `/addsticker` now takes only `[emoji...]`, every one of its arguments is an emoji — +there is no first-token disambiguation to perform, and a stray word fails loudly here rather +than being mistaken for a pack name. + +`models.Sticker.Emoji` is a **single string** (`models/sticker.go:23`), so emoji inherited +from a replied sticker yields at most one element. + +## Related Code Files + +- Create: `internal/modules/sticker/pack.go`, `setname.go`, `sender.go`, `emoji.go` +- Create: matching `_test.go` files for each +- Reference: `internal/storage/doc_store.go:38` (DocStore contract), `keys.go:24-41` +- Reference: `internal/modules/coin/handlers_test.go:36` (memory-store test pattern) +- Reference: `models/message.go:86-87` (`SenderChat`), `models/user.go:12` (`IsBot`), + `models/sticker.go:23` (`Emoji` is one string) + +## Implementation Steps + +1. `pack.go` — record, `packKey`, `getPack`. +2. `sender.go` — `senderID` with the bot/anonymous refusals. +3. `setname.go` — slug validation, `makeSetName`, `ownsSet`, cached resolver interface. +4. `emoji.go` — cluster scanner and `defaultEmoji`. +5. Tests per the Todo list. + +## Todo + +- [x] Define `Pack` incl. `Count` and `Pending`; assert no reserved-bson collision +- [x] `packKey(ownerID)` and `getPack` returning a found flag +- [x] `senderID` rejecting nil/zero/`IsBot`/`SenderChat` with an explanatory message +- [x] `slugRe` validation incl. `__`, trailing `_`, 40-char cap +- [x] `makeSetName` with 64-char guard and budget-reporting error +- [x] `ownsSet` case-insensitive match against stored `Pack.Name` +- [x] `usernameResolver` caching success but never failure, taking the handler's `b` +- [x] `parseEmoji` cluster scanner with the 20-entry cap +- [x] Four test files per the success criteria + +## Success Criteria + +- [x] `getPack` for owner A never returns owner B's pack +- [x] `getPack` on an unknown owner returns `found == false` and a nil error +- [x] No `List` call exists anywhere in the module +- [x] Slug table rejects leading digit, `__`, trailing `_`, 2 chars, 41 chars +- [x] `makeSetName` errors when `len(slug)+len("_by_"+username) > 64` +- [x] `ownsSet` matches `MyPack_by_Bot` against a stored `mypack_by_bot` +- [x] `ownsSet` returns false for a set name belonging to another bot +- [x] A simulated bot username change does **not** break `ownsSet` for an existing pack +- [x] `senderID` rejects `IsBot: true` and a non-nil `SenderChat`, each with zero store access +- [x] `parseEmoji` handles joined input, ZWJ family, flag, keycap, skin tone; rejects plain text; errors above 20 +- [x] `gofmt -l internal/modules/sticker` empty; `go test`/`go vet` clean + +## Risk Assessment + +**Emoji cluster scanning is hand-rolled.** Go has no stdlib grapheme segmentation and a +dependency for this is disproportionate. Signal: a user reports an emoji split or rejected. +Response: extend the table-driven test with the failing sequence. If failures accumulate +across many scripts, take a segmentation dependency. + +**`Count` can drift.** Editing the pack through @Stickers changes the real count without the +bot seeing it. Accepted: the field is advisory and feeds one display column. Phase 3 refreshes +it whenever a command already holds a `GetStickerSet` response, so it self-heals without any +command paying for a lookup it did not otherwise need. + +**Keying on the user ID alone bakes in the one-pack limit.** Reversing to multiple packs later +means a key migration, not just new commands — every existing record would need rewriting +under a compound key. That is the real cost of plan R10, and it is why the limit belongs in +the plan rather than living as a constant someone can bump. diff --git a/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md b/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md new file mode 100644 index 0000000..be70c00 --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md @@ -0,0 +1,275 @@ +--- +phase: 3 +title: "Phase 3: Pack lifecycle commands" +status: done +priority: P1 +effort: "7h" +dependencies: [1, 2] +--- + +# Phase 3: Pack lifecycle commands + +## Overview + +`/newpack`, `/mypack`, `/renamepack`, `/delpack` (+ confirm callback). These own creation and +destruction of the user's single pack, and they carry the plan's two hardest correctness +problems: surviving partial failure, and making an irreversible delete safe to confirm. + +All handlers follow the plan's cross-cutting rules (explicit deadline, `WithoutCancel` +commits, `senderID`, positive error classification). + +## Requirements + +- Functional: create the caller's pack and persist its record such that no interruption can + permanently strand the slug. +- Functional: show, rename, and delete that pack, never another user's. +- Functional: a second `/newpack` while a pack exists is refused with a clear next step. +- Functional: `/delpack` confirmation is bound to invoker, chat, message, and a TTL. +- Functional: `/mypack` makes zero API calls. +- Non-functional: a store record must never claim a pack that does not exist, and a live pack + must never lose its record because of a transient error. + +## Architecture + +### Factory + +Mirrors `internal/modules/coin/coin.go`. `state` holds `store`, `pending` (a second typed view +for delete confirmations — the pattern is idiomatic here; `loldle` and `lol` each build three +views over one collection), `resolver`, `locks keylock.Map`, `nowFn`. + +Registry key is `sticker`; command names stay unprefixed — the registry keys commands by +`cmd.Name` independent of module name (`registry.go:176`), which is why `misc` ships `/ff`. + +`handlerTimeout = 10 * time.Second` is a package constant; every handler opens with it. + +> **Superseded during implementation — see plan.md, "post-implementation review: +> global slug reservation".** Step 5 below adopts an existing set on the strength +> of a `Pending` record for this owner and slug. Review proved that insufficient: +> the pack record is keyed by owner, so a user with *no* pack who types someone +> else's slug produces identical evidence and took over their pack. The shipped +> code adds a create-only global reservation (`slug:` → ownerID) written +> before Telegram is touched, and adopts only when it names the caller. Steps 4-7 +> read as implemented **except** that a reservation check precedes step 4, and +> step 5's "abort, delete the pending record" on an unknown error is wrong for +> the same reason rule 4 exists — the shipped code keeps both the intent and the +> reservation unless the refusal is positively classified. + +### `/newpack ` — write-ahead intent + +`` appears here and nowhere else in the module. It fixes the permanent share URL, and +Telegram has no rename-short-name method, so it cannot be corrected later. + +An earlier draft did "create on Telegram, then write the store", accepting that an interruption +stranded the slug forever. Two review findings killed that: the commit ran on `rootCtx`, which +SIGTERM cancels, so **every deploy** during a `/newpack` stranded a slug; and the +"cannot adopt" rule was not forced by the API's missing owner field (plan C5). The bot does not +need the API to name the owner — it needs its own record of who asked. + +1. `senderID(msg)`; `defer s.locks.Acquire(...)()`. +2. Parse slug and title (1–64 chars). +3. `makeSetName(slug, username)`. +4. **`PutVersioned(ctx, key, 0, Pack{Pending: true, …})`** — the create-only primitive + (`doc_store.go:33-37`; Mongo gives a linearizable single-winner via duplicate-key, + `mongo_doc_store.go:87-105`). This *is* the one-pack quota — no separate counter exists. + On `ErrConflict`, read the record: + - confirmed → "you already have a pack (``). Use /delpack first." Stop. + - `Pending` with the **same** slug → this is our own interrupted attempt; resume at step 5. + - `Pending` with a **different** slug → an earlier attempt was interrupted. **Probe + `GetStickerSet(oldName)` before doing anything.** + - The old set **exists** → the earlier attempt got as far as creating it. Adopt the old + set, commit it, and tell the user they already have a pack (``) and must + `/delpack` first if they want the new name. Do **not** overwrite. + - The old set is **missing** (`isStickerSetMissing`) → nothing was created; overwrite the + pending record with the new slug and continue. + - **Any other error** → unknown; abort without touching the record. + + Overwriting unconditionally would orphan a created-but-uncommitted set permanently: the + set exists and is owned by the user, but adoption keys on the pending slug matching, so + `/newpack ` would afterwards report "taken" with no route back. The probe is what + makes the different-slug branch safe. + + Use `Put` nowhere here — it is a 5-attempt Get→PutVersioned loop + (`mongo_doc_store.go:120-142`) that silently overwrites. +5. `GetStickerSet(name)`: + - **succeeds** → the set exists. We hold a `Pending` record for this owner and slug, so this + is our own interrupted attempt: **adopt it**, jump to step 7. + - **`isStickerSetMissing`** → the slug is free; proceed to step 6. + - **any other error** → unknown. Abort, delete the pending record, reply generic failure. + Never guess (plan rule 4). +6. `CreateNewStickerSet{UserID, Name, Title, Stickers: []InputSticker{{Sticker: fileID, Format: "static", EmojiList: emoji}}}`. + No top-level `sticker_format` — it moved to `InputSticker.Format` in Bot API 7.2 (C7). + On `PACK_SHORT_NAME_OCCUPIED`, another user of this bot holds the slug: delete the pending + record and ask for a different one. +7. Commit: `Put(context.WithoutCancel(ctx), key, Pack{Pending: false, Count: 1, …})`. + Reply with the title and `https://t.me/addstickers/`. + +Re-running `/newpack` with the same slug after any interruption completes the operation instead +of reporting it taken. That is the plan's "interrupted `/newpack` can be completed by re-running" +criterion. + +### `/mypack` — zero API calls + +`getPack(senderID)`. One `Get`. Renders slug, title, `Count`, and the share link, or a short +"you don't have a pack yet — `/newpack `" when absent. A `Pending` record renders +with an "(incomplete — re-run /newpack)" marker rather than being hidden, so a stranded attempt +is visible and fixable. + +This replaces the multi-pack `/packlist`, which issued one `GetStickerSet` per pack. Under plan +C2 each call is bounded only by the library's 60s `http.Client` timeout +(`bot.go:17-18,75-77`), so ten of them on a serialized dispatcher was a ~10-minute bot-wide +freeze from one argument-free public command. That failure mode no longer exists. + +### `/renamepack <title...>` + +`getPack(senderID)`; absent → "you don't have a pack yet". `SetStickerSetTitle{Name, Title}`, +then commit the new `Title` under `WithoutCancel`. + +The reply must state the share link is unchanged **and name the route to a different one**: +`/delpack` then `/newpack <new-slug>`. This matters more now that the command takes no slug — a +user typing "rename" with only a title is even likelier to expect the URL to follow, and it +never can. Pointing at the real path turns a dead end into an answer. + +Required elements of the reply: the new title, the unchanged link, and the delete-and-recreate +route with its cost stated (the stickers do not come along). + +**Reverse gap:** if the API succeeds and the commit fails, `/mypack` shows a title Telegram no +longer has. Cosmetic, self-heals on the next successful rename. Documented, not mitigated. + +### `/delpack` — no argument, bound confirmation + +An earlier draft put the slug in the callback data and re-checked ownership from +`CallbackQuery.From.ID`. That defends against *other* users pressing the button and nothing +else: the payload never expired, was not bound to a chat or message, and lived in scrollback +forever. Three reviewers flagged it independently, and the `stock` module the draft cited as its +model already solves it properly. + +With one pack per user the payload needs no slug at all — but it still needs everything else: + +1. `/delpack` resolves the caller's pack, then writes a pending action: + `pendingDelete{ID, OwnerID, Slug, ChatID, MessageID, ExpiresAt}` with + `pendingDeleteTTL = 10 * time.Minute`. (`stock/pending_dividend.go:12-16,26-34` uses 24h for + a non-destructive action; a destructive one earns a shorter window.) + + The confirm prompt must state all four consequences before the tap, since the command itself + names nothing: + + - the pack title being deleted; + - **the sticker count that will be lost** (`Pack.Count`); + - the exact share link that will stop working; + - that both are permanent. + + `/delpack` is the sanctioned way to change a pack URL, so this prompt is the last point at + which a user learns the stickers do not survive the change. Understating it here is how + someone loses 47 stickers expecting a rename. +2. Callback data is `sticker_pack:d:<opaque id>` — comfortably inside the 64-byte cap (C3). +3. The callback handler: + - returns early when `update.CallbackQuery == nil`; + - resolves the pending action; absent → "this confirmation expired or was already used" + (`stock/dividend_callback.go:66-80` is the model); + - checks `query.From.ID == action.OwnerID` — identity from `From.ID`, **never** the payload; + - checks the chat/message binding and `ExpiresAt`; + - guards `query.Message.Message` for nil — it is a `MaybeInaccessibleMessage` + (`models/message.go:17-21`), nil for messages Telegram marks inaccessible. + `stock/dividend_callback.go:82-83` already guards exactly this. Phase 1's panic barrier is + the backstop, not an excuse to skip the guard; + - deletes the pending action **before** calling `DeleteStickerSet` (single-use); + - `DeleteStickerSet{Name}`, then `store.Delete` under `WithoutCancel`; + - `AnswerCallbackQuery` and clear the button via `EditMessageReplyMarkup` with empty markup, + using `action.ChatID`/`action.MessageID` (`dividend_callback.go:26-33`). + +**Reverse gap:** if `DeleteStickerSet` succeeds and `store.Delete` fails, a phantom record +survives — and under one-pack-per-user that is worse than before, because it blocks `/newpack` +entirely rather than consuming one of ten slots. Mitigation: any command receiving +`isStickerSetMissing` from the API deletes the record on the spot, so the phantom clears on +first contact and `/newpack` works again. + +### Error mapping + +`replyAPIError` matches **MTProto code substrings**, never human text (plan rule 4 / R3). Only +`PACK_SHORT_NAME_OCCUPIED`, `PACK_SHORT_NAME_INVALID`, and `STICKER_EMOJI_INVALID` are rewritten +into prose by the Bot API server; everything else arrives as `Bad Request: <CODE>`. + +| Match | Reply | +|---|---| +| `PACK_SHORT_NAME_OCCUPIED` / "already occupied" | slug taken, pick another | +| `PACK_SHORT_NAME_INVALID` / "invalid sticker set name" | slug rejected by Telegram | +| `PACK_TITLE_INVALID` | title rejected by Telegram | +| `STICKERSET_INVALID` | your pack no longer exists (and delete the record) | +| `STICKERS_TOO_MUCH` | pack is full (120 stickers) | +| `STICKER_EMOJI_INVALID` / "invalid sticker emojis" | emoji rejected | +| `too many emoji specified` | at most 20 emoji per sticker | +| anything else | generic failure; raw error to the dispatcher log | + +## Related Code Files + +- Create: `internal/modules/sticker/sticker.go`, `state.go`, `pack_handlers.go`, + `pending_delete.go`, `delpack_callback.go`, `errors.go`, and their tests +- Reference: `internal/modules/coin/coin.go`, `coin/handlers.go:51` (keylock idiom) +- Reference: `internal/modules/stock/pending_dividend.go:12-34,47-78`, + `stock/dividend_callback.go:17-33,66-83,99-102`, `stock/dividend_notifications.go:303-320` +- Reference: `internal/storage/doc_store.go:33-41`, `mongo_doc_store.go:87-142` + +## Implementation Steps + +1. `state.go`, `sticker.go` wiring four commands + the `sticker_pack:` callback. +2. `errors.go` with `replyAPIError` and `isStickerSetMissing`. +3. `/mypack` first — no mutations, no API calls, easiest to verify. +4. `/newpack` with the write-ahead state machine. +5. `/renamepack`. +6. `pending_delete.go`, `/delpack`, and the callback. +7. Tests per the Todo list, using Phase 1's `StubMethod` / `FailMethodCode`. + +## Todo + +- [x] `state.go` with store, pending view, resolver, locks, nowFn, `handlerTimeout` +- [x] `sticker.go` factory registering the module's commands + callback prefix (9 as shipped, once phases 4-5 landed) +- [x] `errors.go`: `replyAPIError` code table + `isStickerSetMissing` +- [x] `/mypack` reading `Count`, marking a pending record, zero API calls +- [x] `/newpack` steps 1-7 incl. `PutVersioned` intent, three `ErrConflict` branches, adoption +- [x] Different-slug pending branch probes `GetStickerSet(oldName)` before overwriting +- [x] `/renamepack` reply: new title, unchanged link, and the /delpack + /newpack route +- [x] `/delpack` confirm prompt: title, sticker count, link, permanence +- [x] `pending_delete.go` with TTL, chat/message binding, opaque id +- [x] `/delpack` naming the pack in its confirm prompt +- [x] `delpack_callback.go` with expiry, binding, nil-message guard, single-use +- [x] Record self-heal on `isStickerSetMissing` across commands +- [x] Tests per the success criteria + +## Success Criteria + +- [x] `/mypack` records **zero** entries in `RecordingBot.Sent()` +- [x] A second `/newpack` with a confirmed pack present is refused, names the existing slug, and makes zero API calls +- [x] Interrupted `/newpack` (pending record, same slug, set exists) completes on re-run and does not report the slug taken +- [x] Interrupted `/newpack` with a *different* slug where the old set **exists** adopts the old set and refuses the new slug, leaving nothing orphaned +- [x] Interrupted `/newpack` with a *different* slug where the old set is **missing** replaces the pending record and proceeds +- [x] `/newpack` where `GetStickerSet` fails with a non-missing error aborts and never calls `CreateNewStickerSet`, **keeping** the pending record and the reservation — superseded, see the note at the top of this file. Deleting them on an unknown error is what strands a slug: the set may exist, and re-running is how the user recovers. +- [x] `/newpack` uses `PutVersioned(…, 0, …)`; the create path never calls `Put` for a new record +- [x] `/renamepack` with no pack replies "you don't have a pack yet" and makes zero API calls +- [x] `/delpack` confirm after `ExpiresAt` is refused as expired, with no `DeleteStickerSet` +- [x] `/delpack` confirm from a different `From.ID` is refused, with no `DeleteStickerSet` +- [x] `/delpack` confirm with a nil `CallbackQuery.Message.Message` is handled without panic +- [x] Pressing the same confirm twice deletes once; the second press reports already-used +- [x] Callback data is asserted ≤ 64 bytes +- [x] A command receiving `STICKERSET_INVALID` deletes the stale record, unblocking `/newpack` +- [x] Title of 65 chars rejected locally, before any API call +- [x] `/delpack` confirm text contains the pack title, the sticker count, and the share link +- [x] `/renamepack` reply names the `/delpack` + `/newpack` route + +## Risk Assessment + +**The write-ahead state machine is the most intricate logic in the plan**, and one-pack-per-user +adds a branch rather than removing one: `ErrConflict` now means three different things +(confirmed pack, own pending same-slug, own pending different-slug). Its correctness rests on +one property — a `Pending` record for an owner means *that owner* asked for *that name*, and +only this bot can create `*_by_<bot_username>` names. If either half stops holding, adoption +becomes unsafe. Signal: a user reports adopting a pack they did not create. Response: disable +adoption (step 5 becomes "slug taken") and fall back to the documented orphan gap — a one-line +change, deliberately. + +**A stranded pending record now blocks the user entirely.** With ten slots it cost one; with one +pack it blocks `/newpack` until resolved. Mitigated by making it visible in `/mypack` with a +re-run hint, and by the different-slug overwrite branch in step 4 so a user is never wedged by +a name they no longer want. + +**`/delpack` remains irreversible on Telegram's side.** The TTL and bindings reduce accidental +confirmation; they cannot undo a deliberate one. Do not add a `--force` bypass. diff --git a/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md b/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md new file mode 100644 index 0000000..39bc89b --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md @@ -0,0 +1,240 @@ +--- +phase: 4 +title: "Phase 4: Sticker commands (reply path)" +status: done +priority: P1 +effort: "4h" +dependencies: [1, 2, 3] +--- + +# Phase 4: Sticker commands (reply path) + +## Overview + +Per-sticker operations on the caller's pack: `/addsticker` (existing-sticker source), +`/delsticker`, `/editsticker`, `/ordersticker`. All are driven by replying to a sticker. The +photo source arrives in Phase 5 through the same `/addsticker` handler. + +None of these takes a pack argument. Under one-pack-per-user there is nothing to name. + +All handlers follow the plan's cross-cutting rules. + +## Requirements + +- Functional: add an existing static sticker to the caller's pack. +- Functional: remove, re-emoji, and reposition a sticker already in that pack. +- Non-functional: ownership is checked before any API call, and the refusal text is identical + for "another user's pack" and "another bot's pack". +- Non-functional: no transient error may delete a live pack's record. + +## Architecture + +### Shared resolution + +```go +// source of a NEW sticker: whatever the replied message carries. Resolution +// always ends in a file_id usable as InputSticker.Sticker. +type stickerSource struct { + fileID string // static sticker file_id, or a freshly uploaded one (Phase 5) + emoji []string // from the replied sticker; at most one element +} +func (s *state) resolveSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error) + +// an EXISTING sticker in the caller's pack +type ownedSticker struct { + fileID string + pack Pack +} +func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, error) +``` + +`resolveSource` takes `ctx`, `b`, and `ownerID` from the start even though the sticker branch +uses none of them. Phase 5's photo branch needs all three (`GetFile`, an HTTP download, +`UploadStickerFile{UserID}`), and it lives **inside this function**. Declaring the full +signature now means Phase 5 adds a branch instead of rewriting every call site. There is no +`photoRef` field: the photo path resolves to a `fileID` like every other path. + +Its gate: + +1. Require `msg.ReplyToMessage`; else usage error. +2. Sticker branch — reject `IsAnimated`, `IsVideo`, **and `Type != "regular"`** + (`models/sticker.go:16`). A mask or custom-emoji sticker is static yet invalid for a + regular set, so the `IsAnimated || IsVideo` pair alone does not close this. The static-only + gate belongs here, on the path that can actually receive a non-static sticker — not only in + `resolveOwned`, which by construction only ever sees stickers already in a static pack. +3. Otherwise (Phase 5) the photo/document branch; until then, a usage error. + +`resolveOwned` is the single ownership gate for `/delsticker`, `/editsticker`, `/ordersticker`, +and (Phase 5) `/setpackicon`: + +1. Require `msg.ReplyToMessage.Sticker`; else usage error. +2. Require a non-empty `Sticker.SetName`. +3. Reject `IsAnimated`, `IsVideo`, or `Type != "regular"`. Static-only module; defence in + depth. **Before** the store read, so a malformed reply costs nothing and the "rejected + before any API call" criterion holds trivially. +4. `getPack(ownerID)` — **one `Get`**. Absent → the caller has no pack. +5. `ownsSet(pack, sticker.SetName)` (Phase 2) — case-insensitive against the **stored** + `Pack.Name`. False → the sticker is not from the caller's pack. +6. **Steps 4 and 5 must produce byte-identical reply text.** Distinct messages would let a user + probe whether a given set belongs to someone else. Pack *management* refusals stay uniform + even though `/newpack` deliberately discloses slug occupancy (plan's Accepted disclosure + section) — those are different questions. + +This replaced a `listPacks` + match design; with one pack it collapses to a single `Get` and a +string comparison. It still compares against the stored `Pack.Name` rather than a slug +re-derived from the live bot username — Phase 2 explains why (plan R8): a BotFather rename would +otherwise make the user's own pack refuse as "not yours" while `/mypack` still displayed it. + +`models.Sticker.Emoji` is a single string (`models/sticker.go:23`), so `stickerSource.emoji` +from a replied sticker holds at most one element. + +### `/addsticker [emoji...]` + +Reply required. Pack from `getPack`, source from `resolveSource`. Emoji precedence: explicit +args → the replied sticker's emoji → `defaultEmoji`. + +**No pack yet** → "you don't have a pack yet — `/newpack <name> <title>`", zero API calls. +This reply is deliberately **not** the uniform `resolveOwned` refusal, and the difference is +not an oversight: `resolveOwned` is uniform because it answers a question about a set the +caller named, which may be someone else's. `/addsticker` answers only "do *you* have a pack", +about the caller's own state, and discloses nothing about anyone else. Being helpful here +costs no privacy. A `Pending` record counts as no usable pack — same reply, plus the +`/mypack` re-run hint from Phase 3. + +Every argument is an emoji — there is no pack token to disambiguate, so a stray word is caught +by `parseEmoji` and reported as a usage error rather than silently read as a pack name. + +`AddStickerToSet{UserID: ownerID, Name: pack.Name, Sticker: InputSticker{Sticker: fileID, Format: "static", EmojiList: emoji}}`. + +`UserID` is the pack owner, always the caller — the module never lets a non-owner reach this +call. Take the per-user keylock. On success, increment `Pack.Count` and commit under +`WithoutCancel`. `STICKERS_TOO_MUCH` maps to "your pack is full (120 stickers)". + +**Unverified premise — settle before writing this handler.** The whole sticker-source path +assumes `AddStickerToSet` accepts a `file_id` for a sticker that lives in a set this bot did +not create. The Bot API documents `InputSticker.sticker` as accepting "a file_id as a String +to send a file that already exists on the Telegram servers", and says nothing further — but +unlike C1–C11 this was never checked against the live API, and every other API assumption in +this plan was. If Telegram rejects cross-set reuse, this path collapses into Phase 5's +machinery (`GetFile` → download → `UploadStickerFile` → use the returned `file_id`), which +inverts the 4→5 dependency and is much better known before the handler is written than after. +One live call settles it (plan R12). + +### `/delsticker` + +No arguments. `resolveOwned`, then `DeleteStickerFromSet{Sticker: fileID}`. On success, +decrement `Pack.Count` (floor 0) and commit under `WithoutCancel`. + +**Take the per-user keylock**, exactly as `/addsticker` does. Both run the same +read-modify-write on `Pack.Count`, so locking one and not the other would be a half-measure +that only looks safe. `/editsticker` and `/ordersticker` write nothing and take no lock. +Under the lock, the commit uses plain `Put` — the lock is what makes the read-modify-write +safe, so the `PutVersioned` reasoning from Phase 3 (which guards *creation*, not updates) +does not apply. + +Whether removing the final sticker also destroys the set is **not documented** in the Bot API +docs or the open-source Bot API server, so the plan depends on neither answer. + +An earlier draft probed with `GetStickerSet` afterwards and deleted the local record when the +probe "reported not-found" — but never defined how not-found differs from a failed call, and its +own success criterion (`FailMethod` → "record confirmed removed") specified the destructive +reading. Under that design a 429, a DNS blip, or a SIGTERM-cancelled context during a routine +delete would erase the only record of a live pack. With one pack per user that is strictly +worse than it was: the user loses their pack *and* is blocked from `/newpack` until the phantom +clears. + +Corrected: **do not probe.** Decrement the count and stop. If the set really is gone, the next +command returns `STICKERSET_INVALID`, and the shared handler for that (Phase 3) deletes the +record then — a positive signal, per plan rule 4. Simpler and strictly safer. + +**Aftermath of a `Count: 0` pack — state the recovery route.** Not probing means that if +Telegram *did* destroy the set, the user is left holding a record for a pack that no longer +exists. `/mypack` makes zero API calls by design, so it cannot notice; `/newpack` refuses +because a record exists. The user is not wedged — `/delpack` calls `DeleteStickerSet`, gets +`STICKERSET_INVALID`, and Phase 3's self-heal drops the record, freeing `/newpack` — but +nothing in the plan told them that, and this phase is what creates the situation. So: when +the decrement lands on 0, the reply says the pack is now empty, that Telegram may have +removed it, and that `/delpack` clears it if `/addsticker` reports the pack is gone. + +This is also why the success criterion below is scoped rather than absolute. `/delsticker` +must never delete the `Pack` record on a **transient or unknown** error — that is finding R7, +the whole reason the probe was removed. It must still delete it on a **positive** +`STICKERSET_INVALID`, which is Phase 3's cross-command self-heal and the mechanism that +unwedges the user. An unqualified "never deletes the record" would forbid the fix. + +### `/editsticker <emoji...>` + +`resolveOwned`, `parseEmoji` (at least one required — an empty `emoji_list` is invalid), then +`SetStickerEmojiList{Sticker: fileID, EmojiList: emoji}`. + +### `/ordersticker <position>` + +`resolveOwned`, parse a non-negative integer, reject negatives locally. 0-based, stated in the +usage text. Do **not** bound the upper end locally — Telegram validates against the current set +size and a local copy would go stale. Its error goes through `replyAPIError`. + +`SetStickerPositionInSet{Sticker: fileID, Position: pos}`. + +## Related Code Files + +- Create: `internal/modules/sticker/resolve.go`, `sticker_handlers.go`, and their tests +- Modify: `internal/modules/sticker/sticker.go` (register four commands) +- Reference: `internal/modules/util/handlers_test.go:108-118` — an existing test synthesizing + `ReplyToMessage` with a `models.Sticker{FileID, FileUniqueID, SetName, Emoji}`. Exactly the + fixture shape every test here needs. +- Reference: `internal/testutil/update_builders.go` (`NewPrivateMessage`, `NewGroupMessage`) + +## Implementation Steps + +1. `resolve.go` with both helpers and the deliberately uniform not-owned reply. +2. The four handlers in `sticker_handlers.go`, each opening with `handlerTimeout`. +3. Register with `Parameters` per `docs/command-parameter-conventions.md`: + `[emoji...]`, none, `<emoji...>`, `<position>`. +4. Tests per the Todo list. + +## Todo + +- [x] Settle the `file_id`-reuse premise against the live API before writing `/addsticker` +- [x] `resolveSource` with the full `(ctx, b, ownerID, msg)` signature, sticker branch only +- [x] `resolveSource` static-only gate: `IsAnimated`, `IsVideo`, `Type != "regular"` +- [x] `resolveOwned` using `getPack` + `ownsSet`, with the 6-step gate and uniform refusal +- [x] `/addsticker` with emoji precedence and `Count` increment +- [x] `/addsticker` no-pack and pending-pack replies, zero API calls +- [x] `/delsticker` with `Count` decrement, keylock, and **no** probe +- [x] `/delsticker` empty-pack reply naming the `/delpack` recovery route +- [x] `/editsticker` requiring at least one emoji +- [x] `/ordersticker` rejecting negatives locally only +- [x] Register all four with `Parameters` metadata +- [x] `resolve_test.go`, `sticker_handlers_test.go` + +## Success Criteria + +- [x] Each command's happy path asserts the expected method in `RecordingBot.Sent()` +- [x] Missing reply, non-sticker reply, and empty `set_name` each produce a usage error with zero API calls +- [x] "No pack yet" and "sticker from another bot's set" produce **byte-identical** reply text, asserted by comparing the two replies to each other +- [x] A sticker whose `SetName` differs only in case from the stored `Pack.Name` resolves successfully +- [x] `/addsticker` with a non-emoji argument is rejected by `parseEmoji`, not silently reinterpreted +- [x] `/ordersticker -1` rejected locally; `/ordersticker 999` reaches the API +- [x] `/editsticker` with no emoji rejected locally +- [x] `/delsticker` makes exactly one API call, and keeps the `Pack` record on a transient or unknown error +- [x] `/delsticker` receiving `STICKERSET_INVALID` **does** delete the record (Phase 3 self-heal), unblocking `/newpack` +- [x] `/addsticker` and `/delsticker` move `Count` by exactly one, floored at 0 +- [x] A `/delsticker` that lands on `Count: 0` names `/delpack` in its reply +- [x] `/addsticker` with no pack, and with a `Pending` pack, each reply with zero API calls +- [x] `/addsticker` on a full pack maps `STICKERS_TOO_MUCH` to the "pack is full" reply (via Phase 1 `FailMethodCode`) +- [x] Animated, video, **and mask/custom-emoji** (`Type != "regular"`) replies rejected before any API call, on both the source and owned paths + +## Risk Assessment + +**The uniform-refusal requirement is easy to regress.** A later contributor improving the error +copy could split the two messages and reintroduce the disclosure. Mitigation: the test asserts +equality *between the two paths' replies* rather than asserting two fixed strings, so the intent +survives a rewrite of the copy. + +**`resolveOwned` now costs a single `Get`** — no `List`, no fan-out, no API call. This is the +one place the one-pack revision made a correctness-critical path cheaper as well as simpler, +and it removes the caching follow-up the multi-pack version needed. + +**`Count` drift is user-visible but harmless.** Editing the pack through @Stickers desyncs it. +Phase 3 refreshes it whenever a command already holds a `GetStickerSet` response, so it +self-heals without any command paying for a lookup it did not otherwise need. diff --git a/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md b/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md new file mode 100644 index 0000000..85df045 --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md @@ -0,0 +1,212 @@ +--- +phase: 5 +title: "Phase 5: Photo pipeline and pack icon" +status: done +priority: P1 +effort: "8h" +dependencies: [1, 2, 3, 4] +--- + +# Phase 5: Photo pipeline and pack icon + +## Overview + +Turn a replied-to photo (or image document) into a valid static sticker, and add +`/setpackicon`, which needs the same resizing machinery at a different output size. This is +the only phase doing network I/O and CPU work, and the only one handling the bot token. + +## Requirements + +- Functional: a replied photo or image document becomes a sticker with a 512px long edge and + a preserved aspect ratio. +- Functional: `/setpackicon` sets a pack's thumbnail from a sticker already in that pack. +- Non-functional: bounded in bytes and time — it blocks every other user while it runs (C1). +- Non-functional (**security**): the download URL embeds the bot token and must never reach a + log, a reply, or a returned error. + +## Architecture + +### Bounds + +Plan rule 1 already puts `handlerTimeout = 10s` on every handler, which is the outer bound. +This phase adds: + +| Bound | Value | Why | +|---|---|---| +| Source file size | reject above **2 MB** before downloading | Telegram-compressed `photo` sizes are typically well under 500 KB | +| Decoded dimensions | reject above 4096×4096 via `DecodeConfig` | Bounds peak allocation before any pixel buffer exists | +| HTTP client | explicit per-request timeout, not `http.DefaultClient` | The library's shared client is 60s (`bot.go:17-18`) — too long to inherit | + +Worst case is a ~10s bot-wide stall (C1). Bounded and observable, not zero. + +### Telegram's static-sticker format + +PNG or WEBP; **one side exactly 512px**, the other ≤512px. Pack thumbnails differ: PNG or +WEBP, exactly **100×100**, ≤128 KB. + +There is no documented file-size limit for static stickers — the widely-repeated 512 KB +figure appears in no current official page (plan R4). It is a client-side ceiling only and +must not be described as spec in code comments or user-facing text. + +### Source selection + +This is the photo branch of Phase 4's `resolveSource(ctx, b, ownerID, msg)`, which already +takes every parameter this branch needs — no call site changes, and no `photoRef` type: the +branch resolves to a `fileID` like the sticker branch, by uploading first. + +From `msg.ReplyToMessage`: + +- `Photo []PhotoSize` — pick the largest by `FileSize`; do not rely on Telegram's ordering. +- `Document` — accept only `MimeType` of `image/png`, `image/jpeg`, `image/webp`. Reject + anything else *before* downloading. + +Reject when `FileSize > 2<<20`. + +### Download — and the token-leak trap + +`GetFile{FileID}` → `b.FileDownloadLink(f)` (`bot.go:180-182`) → `http.Get`. + +`FileDownloadLink` returns `https://api.telegram.org/file/bot<TOKEN>/<path>`. **Every +transport failure from `http.Client.Do` returns a `*url.Error` whose `Error()` embeds the +full URL**, and `internal/modules/dispatcher.go:136-138` logs a handler's returned error +verbatim. A timeout mid-transfer — trivially reachable — would therefore print the bot token +to stdout, the Coolify log store, and any log shipper. + +The earlier draft's mitigation ("log the `file_id` instead") covered only deliberate logging +and missed this path entirely; its success criterion would have passed while the leak shipped. + +Correct handling, per plan rule 5 — **no error from this package may escape raw**: + +```go +var errDownloadFailed = errors.New("sticker: download failed") + +// ... +if err != nil { + log.Error("sticker download", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) +} +``` + +The original error is discarded, never wrapped — wrapping would keep the URL reachable +through `errors.Unwrap` and `%v`. `classify(err)` maps to a coarse label (`timeout`, +`transport`, `status`) that cannot contain a URL. + +Other rules: + +- `io.LimitReader(body, 2<<20)`; never trust `Content-Length`. +- Read fully into memory — bounded at 2 MB, so no temp files. + +### Decode / resize / encode + +`toStickerPNG(src []byte) ([]byte, error)`: + +1. `image.DecodeConfig` first — reject above 4096×4096 before allocating pixels. +2. `image.Decode` with `image/jpeg`, `image/png`, `image/gif` registered, plus + `golang.org/x/image/webp` (read-only decoder, same module). +3. Scale so the long edge is exactly 512 and the short edge is `round(short*512/long)`, + clamped to ≥1. A square input yields 512×512. +4. `draw.CatmullRom.Scale` into a fresh `*image.NRGBA` — preserves alpha. +5. `png.Encode`. Above the 512 KB client-side ceiling, retry with + `png.Encoder{CompressionLevel: png.BestCompression}`; if still over, step the long edge + down (448, 384, 320). Give up after 320. + +`toThumbnailPNG(src []byte) ([]byte, error)` runs the same pipeline to exactly 100×100, +padding the short edge with transparency to preserve aspect ratio. + +Both are pure functions over `[]byte` so they test without a network. + +### Upload + +`UploadStickerFile{UserID: ownerID, Sticker: &models.InputFileUpload{Filename: "sticker.png", Data: bytes.NewReader(png)}, StickerFormat: "static"}` +→ use the returned `File.FileID` as `InputSticker.Sticker`. + +Two steps, not stylistic: plan C6 shows the form builder honours `attach://` only for +`[]models.InputSticker`, so the single `InputSticker` in `AddStickerToSetParams` cannot carry +raw bytes; `*models.InputFileUpload` *is* handled (`build_request_form.go:87`). +`uploadStickerFile` still takes `sticker_format` even though `createNewStickerSet` lost its +top-level equivalent in Bot API 7.2. + +The returned `file_id` is consumed immediately, so its undocumented validity window never +matters. Do not restructure into upload-now-use-later. + +### `/setpackicon` + +No arguments; reply to a sticker in the caller's pack. + +1. `resolveOwned` (Phase 4). +2. `GetFile` + download that sticker's image, then `toThumbnailPNG`. +3. `SetStickerSetThumbnail{Name: pack.Name, UserID: ownerID, Thumbnail: &models.InputFileUpload{...}, Format: "static"}`. + +The API does accept a `file_id` string for `thumbnail` — the only documented restriction bars +HTTP URLs for animated/video. The reason to resize is the documented 100×100 requirement, +which a 512px sticker's `file_id` does not meet. Confirm in Phase 6's smoke test; if a raw +`file_id` is accepted and auto-resized, this collapses to one call. + +## Related Code Files + +- Modify: `go.mod`, `go.sum` — add `golang.org/x/image` +- Create: `internal/modules/sticker/download.go`, `image.go`, `setpackicon.go`, + `download_test.go`, `image_test.go` +- Modify: `internal/modules/sticker/resolve.go` (photo branch), `sticker_handlers.go` + (`/addsticker` photo path), `pack_handlers.go` (`/newpack` photo path), `sticker.go` +- Reference: `go-telegram/bot@v1.20.0` `bot.go:180-182`, `build_request_form.go:87,105` +- Reference: `internal/modules/dispatcher.go:136-138` (the log path the sentinel protects) + +## Implementation Steps + +1. `go get golang.org/x/image`; confirm a direct require and a clean `go mod tidy`. +2. `download.go` — bounded fetch, own client timeout, sentinel error conversion. +3. `image.go` — `toStickerPNG`, `toThumbnailPNG`. +4. Wire the photo branch into `resolveSource`, then `/addsticker` and `/newpack`. +5. `setpackicon.go` + registration. +6. Tests per the Todo list. + +## Todo + +- [x] Add `golang.org/x/image`; verify `go mod tidy` produces no diff +- [x] `download.go` with 2 MB `LimitReader`, own client timeout, sentinel conversion +- [x] `classify(err)` returning a coarse label that cannot contain a URL +- [x] `toStickerPNG` with DecodeConfig guard, CatmullRom scale, PNG size ladder +- [x] `toThumbnailPNG` at exactly 100×100 with transparent padding +- [x] Photo/document source selection with mime allowlist and 2 MB pre-check +- [x] Wire photo branch into `/addsticker` and `/newpack` +- [x] `/setpackicon` handler and registration +- [x] `image_test.go` with in-test generated fixtures (no committed binaries) +- [x] `download_test.go` asserting no token or URL in any returned error + +## Success Criteria + +- [x] 1024×512 → 512×256; 300×900 → 171×512; 512×512 → 512×512 +- [x] 1×5000 extreme aspect: short edge clamped to ≥1, no panic, no zero-dimension image +- [x] Alpha channel preserved through the resize +- [x] Source above 2 MB rejected with zero HTTP requests made +- [x] Decoded dimensions above 4096×4096 rejected before pixel allocation +- [x] Unsupported document mime rejected before download +- [x] `toThumbnailPNG` output is exactly 100×100 +- [x] **A forced transport failure against an `httptest` server yields an error whose text contains neither `"bot"` nor the URL** — asserted, not assumed +- [x] `go mod tidy && git diff --exit-code go.mod go.sum` clean + +## Risk Assessment + +**R1 — bot-wide stall.** Under C1 every photo request blocks all users for up to +`handlerTimeout`. Tight bounds cap the damage but do not remove it; a user in a loop can keep +the bot substantially stalled. + +- Signal: reply latency for unrelated commands spikes with image traffic. +- Response, in order: (a) lower `handlerTimeout` and the 2 MB cap; (b) offload the pipeline + to a detached goroutine that acks immediately and replies on completion, mirroring + `dispatcher.go:80-84`; (c) if neither suffices, revisit the Public visibility decision with + the user — their call, not a unilateral change. +- **If (b) is ever taken, C1's single-in-flight guarantee disappears**, and two things become + mandatory together: a package-level semaphore around image decoding, and a real look at the + `/newpack` quota check, which becomes genuinely racy rather than merely lock-protected. The + two are linked deliberately so neither is done without the other. + +**Untrusted image decoding.** Bounded by size and dimension checks; Go's decoders are +memory-safe. Peak allocation is ~64 MB per conversion at the 4096² cap, and C1 guarantees one +at a time. Phase 1's panic barrier is the backstop for a decoder panic — but it is a backstop, +not a licence to skip the dimension guard. + +**New dependency.** `golang.org/x/image` is the only one in the plan and the repo's first +*direct* `golang.org/x/*` requirement. Confined to `image.go`. If resampling disappoints, +swapping `CatmullRom` for `ApproxBiLinear` is one line (plan R9). diff --git a/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md b/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md new file mode 100644 index 0000000..6ec2c7f --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md @@ -0,0 +1,203 @@ +--- +phase: 6 +title: "Phase 6: Wiring, menu, docs" +status: partial +priority: P2 +effort: "4h" +dependencies: [1, 2, 3, 4, 5] +--- + +# Phase 6: Wiring, menu, docs + +## Overview + +Register the module, make enabling it an explicit operator decision, and bring the user-facing +surfaces named in `AGENTS.md` § "Command Changes" into line. Nothing from Phases 2–5 is +reachable by a user until this phase lands. + +## Requirements + +- Functional: the module is enabled only by an explicit `MODULES` entry. +- Functional: all nine commands appear in `/help` and the native menu with correct metadata. +- Non-functional: `/help` stays under the 4096-rune ceiling the test suite enforces. +- Non-functional: no stats migration — nothing is renamed or deleted. + +## Architecture + +### Enablement must come before registration + +`internal/modules/registry.go:107-116` expands an empty `MODULES` to **every** registered +factory, and its own comment calls that the documented contract. The repo ships +`.env.example:16` as `MODULES=` (empty) and `compose.yml:14` documents "empty = all modules". + +So adding `"sticker": sticker.New` to `factories()` **is** the enablement: a public, +write-capable module would go live on the next deploy with no operator decision. An earlier +draft claimed the opposite in three places — goal, requirement, and a success criterion a +reviewer would have ticked without testing. + +Ordered fix, per the user's decision: + +1. **First**, set `MODULES` explicitly in the deployed environment to the current eleven + modules, and verify the bot restarts with an unchanged command set: + `util,misc,amlich,monkeyd,wordle,loldle,lol,stock,gold,coin,stats` +2. **Then** add the `factories()` entry and merge. +3. Add `sticker` to `MODULES` when the operator chooses to turn it on. + +Only after step 1 is "remove `sticker` from `MODULES`" a genuine zero-deploy rollback. Before +it, that rollback means enumerating eleven module names into an empty variable under pressure. + +Update `.env.example` with the explicit list and a comment stating why, so a fresh clone does +not reintroduce the empty-means-everything trap. + +### Registration + +One line in `factories()` (`cmd/server/main.go:83`). Plain string key, matching `util`, `misc`, +and `gold`; the `CollectionName` constant form in `lol`/`coin`/`stock` exists because those +packages reuse the name elsewhere, which this one does not. + +`Build` validates names against `^[a-z0-9_]{1,32}$` (`validate.go:10`) and rejects duplicates +(`registry.go:173`). All nine were verified free against the current registry; re-run registry +tests to catch later additions. Note `mypack` replaced `packlist` in the one-pack revision — +re-verify that name specifically, since it was not part of the original conflict check. + +### The `/help` rune budget + +`cmd/server/command_menu_test.go:110-113` renders the full `/help` body and `t.Fatalf`s above +`telegramMessageMaxRunesForTest = 4096` (`:116-119`). + +Measured at HEAD: **3212 runes, 45 public commands, 11 modules — 884 runes of headroom.** + +The one-pack revision helps here. Each help line is +`InvocationSentence() + " " + SummarySentence()` (`internal/modules/command_presentation.go:16-24`), +so `Parameters` counts against the budget — and four commands lost their `<pack>` token: + +| Command | Was | Now | +|---|---|---| +| `/addsticker` | `<pack> [emoji...]` | `[emoji...]` | +| `/renamepack` | `<pack> <title...>` | `<title...>` | +| `/delpack` | `<pack>` | — | +| `/packlist` → `/mypack` | — | — | + +That is roughly 25 runes recovered across the module, leaving about **98 runes per command +line** including the module header rather than ~85. Still not generous: `/ordersticker <position>.` +alone is 24 runes, leaving ~74 for its description. + +Write the nine descriptions against that budget *before* wiring, then re-measure — the figure +above is derived, not measured post-change. If they do not fit, decide then whether `/help` +needs pagination; that is a separate change, and it must not be "solved" by trimming other +modules' descriptions. + +### Test impact — which tests, and which only look related + +| Test | Uses real `factories()`? | Action | +|---|---|---| +| `main_test.go:111` `TestFactoriesIncludesExpectedModules` | No — builds only `{"gold","coin"}` | **No change needed** | +| `command_menu_test.go:19` `TestBotCommandMenu_...ModuleOrder` | No — synthetic `alpha`/`beta` | **No change needed** | +| `command_menu_test.go:54` `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` | **Yes** — `modules.Build(nil, factories(), …)` | **Add all nine to `expectedParameters`**; also enforces ≤256-rune descriptions, no `Eg:`, no newlines, and the 4096-rune `/help` ceiling | +| `command_menu_test.go:121` `TestBotCommandMenu_StockDividendContracts` | Stock-specific | No change | + +`expectedParameters` entries for the nine: `newpack` → `<pack> <title...>`, `addsticker` → +`[emoji...]`, `delsticker` → ``, `editsticker` → `<emoji...>`, `ordersticker` → `<position>`, +`setpackicon` → ``, `renamepack` → `<title...>`, `delpack` → ``, `mypack` → ``. + +**`<name...>` is not yet a documented form.** `docs/command-parameter-conventions.md:15-20` +defines `<name>`, `<name,...>`, `[name]`, and `[name...]` — required *remaining text* appears +nowhere, and no existing command uses it (`rg "Parameters:"` across `internal/` confirms). +Three of the nine (`<title...>` twice, `<emoji...>`) need it. Add the row +`| Required remaining text | \`<name...>\` | \`<title...>\` |` to that table and an example +line, in the same change that registers the commands — the conventions doc is the authority +these registrations are validated against, so shipping an undocumented form silently +demotes it. + +### README and docs + +Module table row listing the nine commands, then a `### Sticker packs` section: **one pack per +user**; the pack is created on behalf of the calling user; the bot manages only packs it +created; the slug is chosen once at `/newpack` and fixes a permanent share link that +`/renamepack` cannot change; static stickers and photos only; the 120-stickers-per-pack cap; +anonymous group admins are not supported and why. + +Note the relationship to `/stickerid` in `util` — it stays put. It is a private debug helper for +reading a `file_id`, not pack management. + +`docs/sticker-packs.md` carries: the full command reference with `Parameters` matching handler +usage text exactly (`docs/command-parameter-conventions.md` § Change Checklist); slug rules and +the permanence of the resulting URL; the image contract (512px long edge, PNG; thumbnails +100×100); what is deliberately absent and why (usage-statistics commands — no Bot API support; +animated/video/emoji packs — out of scope; multiple packs per user — see plan R10; +conversational flow — no message hook at `internal/modules/dispatcher.go:66`); the accepted +slug-occupancy disclosure; and the `Count` drift note. + +### Stats compatibility + +No command is renamed or deleted **in the shipped bot** — `/packlist` never existed outside this +plan, so its replacement by `/mypack` needs no migration. `AGENTS.md` § "Stats Compatibility" +governs renames of live commands and does not apply. New commands accrue stats through the +dispatcher hook (`dispatcher.go:80-84`). + +## Related Code Files + +- Modify: deployed environment `MODULES` (**before** the code change), `.env.example` +- Modify: `cmd/server/main.go` (factory entry + import) +- Modify: `cmd/server/command_menu_test.go` (`expectedParameters`, +9) +- Modify: `README.md` +- Create: `docs/sticker-packs.md` + +## Implementation Steps + +1. Set `MODULES` explicitly in the deployed environment; verify an unchanged command set. +2. Draft the nine descriptions against the ~98-rune budget; measure `RenderHelp` locally. +3. Add the factory entry and import. +4. Update `expectedParameters`; run `go test ./cmd/server/...`. +5. README row + section; `docs/sticker-packs.md`. +6. Full gate, then the manual smoke sequence. + +## Todo + +- [ ] Set `MODULES` explicitly in the deployed environment and verify +- [x] Update `.env.example` with the explicit list and a why-comment +- [x] Confirm `mypack` is free in the registry alongside the other eight +- [x] Draft nine descriptions within the measured budget and re-measure `RenderHelp` +- [x] Add `"sticker": sticker.New` to `factories()` +- [x] Add nine entries to `expectedParameters` +- [x] Add the `<name...>` row + example to `docs/command-parameter-conventions.md` +- [x] README module-table row + `### Sticker packs` section +- [x] `docs/sticker-packs.md` +- [x] Full validation gate +- [ ] Manual smoke sequence against a real token + +## Success Criteria + +- [x] `gofmt -l .` empty; `go vet ./...` clean; `go test ./...` passes; `golangci-lint run` clean +- [x] `RenderHelp` stays under 4096 runes with all nine commands registered +- [ ] With `MODULES` unset in a scratch environment, the module still loads — confirming C10 is understood rather than assumed away +- [ ] With the explicit `MODULES` list and no `sticker` entry, none of the nine commands register +- [ ] Smoke: reply to a sticker with `/newpack smoke_pack Smoke Pack`; the returned link opens +- [ ] Smoke: a second `/newpack` is refused and names the existing pack +- [ ] Smoke: reply to a photo with `/addsticker 😂` — no pack named — and the sticker renders undistorted +- [ ] Smoke: `/mypack`, `/renamepack New Title`, `/setpackicon`, `/ordersticker 0` all succeed +- [ ] Smoke: `/renamepack` leaves the share link working and unchanged +- [ ] Smoke: a **second** account replying to the first account's sticker with `/delsticker` is refused +- [ ] Smoke: an anonymous group admin is refused with the explanatory message +- [ ] Smoke: `/delpack` confirm prompt shows the title, sticker count, and link before confirming +- [ ] Smoke: `/delpack` → confirm → link 404s; a second press reports already-used; `/newpack` then works again +- [ ] Smoke: **after deleting, attempt `/newpack` with the same slug** — this settles plan R11. Record the outcome in `docs/sticker-packs.md` either way, and if the slug is reserved, add that to `/delpack`'s confirm text +- [ ] Smoke: `/renamepack` reply names the delete-and-recreate route +- [ ] Smoke: observed error strings for an occupied slug and a full pack match `replyAPIError`, or the table is corrected + +## Risk Assessment + +**The error-code table is unverified until this phase.** Only three MTProto codes are rewritten +into prose by the Bot API server; the rest were inferred from the open-source server's rewrite +table, not a live reproduction (plan R3). The smoke sequence deliberately includes an +occupied-slug and a full-pack case. A mismatch is a polish gap, not a blocker — the generic +fallback means users see a sane message either way. + +**Manual smoke is the only Telegram-side coverage.** Every automated test uses `RecordingBot`, +which never contacts Telegram. CI cannot catch a wrong parameter name or a rejected image +format. The smoke sequence is mandatory before announcing the feature. + +**Enablement ordering is a process risk, not a code one.** If step 1 is skipped and the factory +entry merges first, the module goes live unannounced. Signal: the deployed bot answers +`/mypack` before anyone enabled it. Response: set `MODULES` immediately; the module is otherwise +harmless until someone runs a command. diff --git a/plans/260824-1051-sticker-pack-module/plan.md b/plans/260824-1051-sticker-pack-module/plan.md new file mode 100644 index 0000000..c4e9d5a --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/plan.md @@ -0,0 +1,588 @@ +--- +title: "Sticker packs module" +description: "internal/modules/sticker — public, one-pack-per-user Telegram sticker set management via single-shot reply commands using @Stickers command names" +status: partial +priority: P2 +effort: "" +tags: ["sticker", "telegram-bot", "module"] +created: 2026-08-24 +branch: main +blockedBy: [] +blocks: [] +--- + +# Sticker packs module + +## Overview + +New module `internal/modules/sticker` letting **any** user create and manage **one** +personal Telegram sticker pack through the bot. The pack is created on behalf of the calling +user (`user_id`), named `<slug>_by_<bot_username>`, and stays bot-manageable because the bot +created it. + +One pack per user is the central simplification: no command except `/newpack` takes a pack +argument, because there is only ever one pack to act on. + +Command names mirror @Stickers (`/newpack`, `/addsticker`, …) but each command is +**single-shot**: one message carrying its arguments, optionally replying to a sticker or +photo. Commands are **unprefixed**, like the `misc` module (`/ff`, `/random`). + +Phase 1 fixes two shared-code gaps this module would otherwise expose. They are +prerequisites, not incidental work. + +## Goals + +| # | Goal | Priority | +|---|------|----------| +| 1 | Any user can create and fill their personal sticker pack without leaving the chat | P1 | +| 2 | No command but `/newpack` requires naming a pack | P1 | +| 3 | Ownership is enforced structurally — a user can never mutate another's pack | P1 | +| 4 | No sticker command can stall the bot for other users beyond a bounded deadline | P1 | +| 5 | A partial failure never permanently strands a user's pack | P1 | +| 6 | Command names and semantics recognisable to @Stickers users | P2 | +| 7 | Enabling the module is an explicit operator decision | P2 | + +## Accepted scope + +| Decision | Value | +|---|---| +| Visibility | `VisibilityPublic` — every user manages their own packs | +| Pack model | **One pack per user.** The slug is chosen at creation and never used as an argument again | +| Inputs | Existing static stickers (reply) + photos/image documents (reply) | +| Interaction | Single-shot reply + args; no conversation state, no `/cancel` | +| Naming | @Stickers command names, no module prefix | + +## Command surface + +All `VisibilityPublic`. `Parameters` follows `docs/command-parameter-conventions.md`. + +| Command | Parameters | Reply required | API calls | +|---|---|---|---| +| `/newpack` | `<pack> <title...>` | yes (sticker/photo) | `GetStickerSet`, `UploadStickerFile`*, `CreateNewStickerSet` | +| `/addsticker` | `[emoji...]` | yes (sticker/photo) | `UploadStickerFile`*, `AddStickerToSet` | +| `/delsticker` | — | yes (sticker in own pack) | `DeleteStickerFromSet` | +| `/editsticker` | `<emoji...>` | yes (sticker in own pack) | `SetStickerEmojiList` | +| `/ordersticker` | `<position>` | yes (sticker in own pack) | `SetStickerPositionInSet` | +| `/setpackicon` | — | yes (sticker in own pack) | `GetFile`, `SetStickerSetThumbnail` | +| `/renamepack` | `<title...>` | no | `SetStickerSetTitle` | +| `/delpack` | — | no (inline confirm) | `DeleteStickerSet` | +| `/mypack` | — | no | **none** — count comes from the store | + +`*` only on the photo path. + +### Slug is a name, not an address + +`<pack>` survives on `/newpack` alone, where it fixes the permanent share URL +`t.me/addstickers/<slug>_by_<bot>`. Telegram has no rename-short-name method, so that choice +is unfixable afterwards — which is exactly why it stays user-chosen rather than derived from +a user ID (which would publish the owner's numeric Telegram ID forever) or generated opaquely. + +Every other command resolves the caller's single pack from the store, or from the replied +sticker's `set_name` matched against it. This supersedes the earlier multi-pack design in +which `<pack>` was an argument to `/addsticker`, `/renamepack`, and `/delpack`. + +### Dropped from @Stickers + +`/stats`, `/top`, `/packstats`, `/packtop`, `/topbypack`, `/packusagetop` report sticker +**usage counts**, which the Bot API does not expose. `/stats` is also already owned by the +`stats` module. `/newanimated`, `/newvideo`, `/newemojipack`, `/newmasks` are outside the +accepted static-only scope. `/cancel` is meaningless without conversation state. + +## Architecture constraints (verified against this repo and the live API) + +Each was checked against source, not assumed. C1–C8 survived adversarial review; C5 was +corrected. + +### C1 — Handlers are globally serialized + +`internal/telegram/client.go:27` passes `bot.WithNotAsyncHandlers()`, and the library's +`defaultWorkers = 1` (`bot.go:20`) with no `WithWorkers` override. `process_update.go:26-28` +runs the handler inline. One slow handler stalls **every** user. + +Caveat found in review: "one update at a time" is not "one goroutine". The cron scheduler +(`cmd/server/main.go:163`) and the detached per-command stats hook +(`internal/modules/dispatcher.go:80-84`) both run concurrently with handlers. Neither +touches pack state today, but the per-user keylock is therefore **not** redundant. + +### C2 — No per-update deadline, and the library's own ceiling is 60s + +Handler ctx is `rootCtx` (`cmd/server/main.go:107,214`), which has no deadline, so +`chathelper.FetchContext` (`chathelper.go:107-113`) returns a bare `WithCancel` that bounds +nothing. The only remaining ceiling is the library's shared +`http.Client{Timeout: time.Minute}` (`bot.go:17-18,75-77`). + +Consequence: **every** handler needs its own explicit deadline, not just the photo path. +Ten sequential API calls under a 60s per-call ceiling is a ~10-minute bot-wide freeze. + +### C3 — Callback data caps at 64 bytes + +`internal/modules/stock/pending_dividend.go:16-17` enforces `maxDividendCallbackBytes = 64` +against Telegram's limit. `/delpack` carries an opaque pending-action id, not a slug, so the +budget is comfortable. + +### C4 — Callback prefix conflicts are checked bidirectionally + +`internal/modules/registry.go:216-219`. `sticker_pack:` does not overlap `stock_div:`, the +only existing prefix. + +### C5 — The API cannot prove ownership, so the bot must record intent before acting + +`getStickerSet` returns only `name`, `title`, `sticker_type`, `stickers`, `thumbnail` +(`models/sticker_set.go:4-10`) — no owner field. + +The earlier draft concluded "therefore orphaned sets can never be adopted". Review showed +that does not follow: the bot does not need the API to name the owner, it needs **its own +record of who asked for that name**. Since only this bot can create `*_by_<bot_username>` +sets, a write-ahead intent record makes an existing set attributable. See R2 and Phase 3. + +### C6 — Raw bytes cannot ride on `AddStickerToSet` + +`build_request_form.go:105` handles `attach://` only for `[]models.InputSticker`. The single +`InputSticker` in `AddStickerToSetParams` (`methods_params.go:905-909`) falls through to +`addFormFieldDefault`, and `StickerAttachment` is `json:"-"` (`models/sticker.go:40`), so it +is silently dropped. Photo path must be `UploadStickerFile` (which accepts +`*models.InputFileUpload`, `build_request_form.go:87`) → use the returned `File.FileID`. + +### C7 — Library is current for stickers + +Bot API is at 10.3 (2026-08-24); last sticker changes were Bot API 7.2 (2024-03-31). +`v1.20.0` has `InputSticker.Format`, no top-level `sticker_format` on +`CreateNewStickerSetParams`, and `ReplaceStickerInSet`. No known gap. + +### C8 — Telegram limits (confirmed against official docs) + +| Limit | Value | +|---|---| +| Set name | 1–64 chars, letters/digits/underscore, begins with a letter, no consecutive underscores, ends `_by_<bot_username>` (case-insensitive) | +| Static sticker image | PNG or WEBP; one side **exactly** 512px, other ≤512px | +| Static sticker file size | **Not documented.** The widely-repeated 512 KB figure appears in no current official page (R4) | +| Set thumbnail | PNG/WEBP, exactly 100×100, ≤128 KB | +| Stickers per set | 120 regular/mask, 200 custom emoji | +| Emoji per sticker | 1–20 | +| Set title | 1–64 chars | +| Sets per bot | **Not documented** — no known ceiling | + +### C9 — There is no panic barrier on the update path + +`rg "recover()"` finds four sites: `testutil/mongotest`, `server/log_middleware.go:50`, +`monkeyd/export_job.go:52`, `cron/scheduler.go:67`. **None on the command or callback path.** +`internal/modules/dispatcher.go:167` carries a stale comment promising "our `recover()` in +webhook.go"; `internal/telegram/webhook.go` contains only `DeleteWebhook`. + +With C1, a panic in any handler terminates the process. Phase 1 closes this. + +### C10 — `MODULES` is not opt-in + +`internal/modules/registry.go:107-116` expands an empty list to every registered factory, +and its own comment calls that the documented contract (`.env.example:16` ships `MODULES=` +empty; `compose.yml:14` says "empty = all modules"). Adding a `factories()` entry **is** the +enablement. Phase 6 sets `MODULES` explicitly before merging. + +### C11 — `RecordingBot` cannot return structured results + +`internal/testutil/recording_bot.go:178-195` answers every non-message-producing method with +`{"ok":true,"result":true}`. `GetStickerSet`, `GetFile`, and `UploadStickerFile` decode into +structs, so under the current harness they can only ever **error**. `FailMethod` +(`:99-112`) emits no `error_code`, so library errors in tests never take the +`ErrorBadRequest` shape production emits. Phase 1 extends the harness. + +## Phases + +| # | Phase | Status | +|---|-------|--------| +| 1 | [Phase 1: Shared prerequisites](./phase-01-shared-prerequisites.md) | Done | +| 2 | [Phase 2: Store, set names, emoji parsing](./phase-02-store-setname-emoji.md) | Done | +| 3 | [Phase 3: Pack lifecycle commands](./phase-03-pack-lifecycle.md) | Done | +| 4 | [Phase 4: Sticker commands (reply path)](./phase-04-sticker-commands.md) | Done | +| 5 | [Phase 5: Photo pipeline and pack icon](./phase-05-photo-pipeline.md) | Done | +| 6 | [Phase 6: Wiring, menu, docs](./phase-06-wiring-docs.md) | Partial — code + docs done; live smoke and deployed MODULES pending | + +## Dependencies + +Phase 1 blocks everything (shared code + test harness). Phase 2 blocks 3, 4, 5. Phase 3 +blocks 4 and 5. Phase 5 depends on Phase 4's `/addsticker` handler. Phase 6 last. No +cross-plan dependencies — the only other plan is completed and touches disjoint files. + +## Cross-cutting rules + +These apply to every handler in Phases 3–5. Stated once here rather than repeated. + +1. **Explicit deadline.** Every handler opens with + `ctx, cancel := context.WithTimeout(ctx, handlerTimeout)` (`handlerTimeout = 10s`, + package constant). Required by C2 — nothing else bounds a call. +2. **Durable writes survive shutdown.** Store writes that commit a completed Telegram-side + action use `context.WithoutCancel(ctx)` plus a short timeout, mirroring the existing + idiom at `dispatcher.go:78-84`. `rootCtx` is cancelled by SIGTERM mid-handler, so a + plain `ctx` write fails on every deploy (R2). +3. **One sender helper.** `senderID(msg) (int64, error)` rejects a nil `From`, `From.IsBot`, + and any message carrying `SenderChat`. Anonymous group admins share a single + `GroupAnonymousBot` id, so without this all anonymous admins across all groups share one + pack namespace and one quota (R6). +4. **Positive error classification only.** `isStickerSetMissing(err)` is + `errors.Is(err, bot.ErrorBadRequest) && strings.Contains(err.Error(), "STICKERSET_INVALID")`. + Any other error is "unknown — abort with no side effects". Never infer "absent" from a + generic failure (R3, R7). +5. **Errors from the download path never escape raw.** They are converted to a sentinel at + the boundary, discarding the original (R5). + +## New dependency + +`golang.org/x/image` — for `draw.CatmullRom` resampling. Stdlib decodes JPEG/PNG and encodes +PNG but ships no scaler, and stickers need an exact 512px long edge. Note it becomes the +repo's first *direct* `golang.org/x/*` requirement; all current ones are indirect. + +## Abuse surface + +Public module creating durable Telegram-side objects on a single-threaded dispatcher (C1): + +- One pack per user, enforced by a create-only write before `CreateNewStickerSet`. This is + the quota; there is no separate counter to keep. +- `handlerTimeout = 10s` on every handler — the primary bound (C2). +- `/mypack` makes **zero** API calls; the count lives on the `Pack` record. A single `Get`, + no `List`, no per-pack fan-out. +- Photo source rejected above 2 MB; decoded dimensions capped at 4096×4096. +- Slug alphabet `^[a-z][a-z0-9_]{2,39}$`, no `__`, no trailing `_`. +- `internal/keylock` per-user serialization — genuinely load-bearing, not decorative, + because crons and the detached stats hook run concurrently with handlers (C1 caveat). +- Anonymous/bot senders refused outright (cross-cutting rule 3). + +## Risks + +| # | Risk | Signal it broke | Response | +|---|---|---|---| +| R1 | A handler stalls the bot for all users (C1) | Reply latency spikes for unrelated commands | `handlerTimeout` caps it at 10s; if still felt, lower it, then consider offloading the photo path (Phase 5 R1) | +| R11 | A deleted slug may not be reclaimable | `/newpack <old-slug>` after `/delpack` reports the slug taken | Unknown at decision time; official docs are silent and community reports lean toward short names staying reserved. Does **not** block the URL-change path, which needs a *different* name. Settled empirically in Phase 6 smoke; if reserved, say so in `/delpack`'s confirm text | +| R10 | A user wants two packs and cannot have one | Requests for a second pack | Accepted by design. Reversing it means restoring `<pack>` arguments across four commands — a deliberate, not incidental, change | +| R2 | Partial failure strands a pack | User reports a slug reported taken that `/mypack` does not show | Write-ahead intent + `WithoutCancel` commits (Phase 3). Reverse gaps for `/delpack` and `/renamepack` documented in Phase 3 | +| R3 | Error-code matching drifts | Users see the generic reply where a specific one was expected | Match MTProto **codes**, never human text; confirm empirically in Phase 6 smoke | +| R4 | The 512 KB static-sticker ceiling may not be real | Uploads succeed above it, or fail below it | Client-side ceiling only; never stated as spec in user-facing text | +| R5 | Bot token leaks through a transport error | Any log line containing `api.telegram.org/file/bot` | Sentinel conversion at the download boundary + a test asserting the error text is clean (Phase 5) | +| R6 | Ownership collapses for anonymous senders | Two users see each other's packs | Cross-cutting rule 3 refuses them before any store access | +| R7 | A transient error deletes a live pack's record | `/mypack` reports no pack though the user can still open theirs via link | Rule 4 — destructive store deletes require positive `STICKERSET_INVALID` | +| R8 | Bot username changes in BotFather | Every pack refuses as "not yours" after restart | Ownership matches stored `Pack.Name` against `Sticker.SetName`; username only builds *new* names (Phase 2) | +| R9 | `golang.org/x/image` resampling disappoints | Visibly soft or aliased stickers in smoke | Swap `CatmullRom` for `ApproxBiLinear`; one line, one file | +| R12 | `AddStickerToSet` may reject a `file_id` from a set this bot did not create | `/addsticker` on a sticker from any other pack fails while the photo path works | The one API assumption in this plan never checked against the live API — the docs allow a `file_id` in `InputSticker.sticker` but are silent on provenance. Settle with one live call before writing Phase 4's handler. If rejected, `/addsticker`'s sticker path routes through Phase 5's `GetFile` → download → `UploadStickerFile`, which makes Phase 4 depend on Phase 5 rather than the reverse — cheap to know first, expensive to discover after | + +## Accepted disclosure + +`/newpack` answers "is this slug taken?" for any slug, which reveals that *some* user of this +bot owns it. This is accepted, not solved: `t.me/addstickers/<slug>_by_<bot>` is publicly +probeable without the bot, so the command adds no information an attacker lacks. The earlier +draft claimed no disclosure while shipping this probe — the claim was wrong and is removed. +Pack *management* refusals remain deliberately uniform (Phase 4), because those would +otherwise disclose which of the caller's own guesses correspond to real packs. + +## Success Criteria + +- [ ] A non-admin user can reply to a sticker with `/newpack mypack My Pack` and receive a working `t.me/addstickers/mypack_by_<bot>` link +- [ ] `/addsticker 😂` on a photo produces a valid static sticker with a 512px long edge and correct aspect ratio, with no pack named +- [ ] Managing a pack the caller does not own fails with an ownership error and makes zero API calls +- [ ] The not-owned reply text is byte-identical whether the set is another user's or another bot's +- [ ] `/mypack` shows slug, title, count, and link for the caller's own pack, making no API calls +- [ ] A second `/newpack` while a pack exists is refused, naming the existing pack and pointing at `/delpack` +- [ ] `/delpack` takes no argument and requires inline confirmation bound to invoker, chat, message, and a TTL +- [ ] `/delpack`'s confirm prompt states the title, sticker count, link, and permanence before the tap +- [ ] `/renamepack`'s reply names the `/delpack` + `/newpack` route to a different URL +- [ ] Every handler is bounded by an explicit deadline; no handler can exceed `handlerTimeout` +- [ ] A panic in any module handler is contained and logged, and does not terminate the process +- [ ] An interrupted `/newpack` can be completed by re-running the same command +- [ ] Anonymous group admins and bot senders are refused before any store or API access +- [ ] No log line or error string contains the file-download URL +- [ ] Module is enabled only by an explicit `MODULES` entry, verified in a deployed environment +- [ ] `go test ./...`, `go vet ./...`, `gofmt -l .`, and `golangci-lint run` all clean + +## Open Questions + +None. Both prior questions were resolved by the one-pack-per-user revision: `/editsticker` +takes no pack argument because no command but `/newpack` does (former O1), and the +per-user pack limit is one (former O2). + +## Design Revisions + +### 2026-08-25 — /delpack confirmations must prove authority, not disprove it + +Removing adoption closed the takeover class, but the same DeleteStickerSet +primitive stayed reachable through a stale confirmation. The under-lock re-check +was written as a blocklist — it refused only a *pending* record still naming the +set — and fell through on the two states that mattered: no record at all, and a +record that had moved on. Reproduced with ordinary commands and no attacker: +prompt, pack disappears at Telegram, self-heal frees the name, another user +claims it, first user presses, their pack is destroyed. + +Inverted to an allowlist: delete only when a confirmed record still names this +exact set. A guard phrased as "which states do I refuse" cannot fail closed +against a state nobody enumerated. Dropping a pack record also clears any +outstanding confirmation, so a dead prompt is gone rather than merely refused. + +This also closes the reservation leak on the confirmed-delete path, since the +moved-on case no longer reaches Telegram at all. + +Fixed alongside: resuming an interrupted `/newpack` silently discarded a retyped +title and reported success with the old one; a test named for freeing a dead +name never asserted it; and `lockUser`'s comment justified the lock with cron +and stats-hook contention that does not exist — the map is state-local and this +module registers neither. + +### 2026-08-25 — pack adoption removed + +Verification found the round-4 guard defeated by the module's own recovery +branch: an inconclusive `GetStickerSet` keeps the reservation, which turns a +*fresh* claim into a *resumed* one, so two ordinary `/newpack` commands took +over a stranger's pack. `resolveStaleIntent` had a second adopt path that never +consulted the guard at all. Reproduced against the real handlers. + +That is the fourth consecutive failure of the same mechanism, and the reason is +structural rather than a bug that can be patched. Adoption needs to prove "this +set is mine to finish" from local state, and local state is exactly what a +restart on the in-memory backend erases while the packs at Telegram survive. +Once the proof is gone the honest and the malicious case are indistinguishable. + +Adoption is therefore removed entirely — both branches. `/newpack` refuses any +name a set already occupies, for everyone, whatever the records say. + +Implementing it surfaced a second hole the option did not cover: a *pending* +record is not evidence either, and `/delpack` deletes by set name, which +Telegram authorises for every set this bot created. Keeping a refused intent so +the user could `/delpack` it would hand over a way to destroy the pack we had +just refused to adopt. `/delpack` now clears a pending record locally and calls +Telegram only for a confirmed one. + +Accepted cost: a crash between creating a set and recording it strands that set +permanently. Documented rather than mitigated, because every mitigation is the +mechanism that just failed four times. + +### 2026-08-25 — three-lens review pass (security, correctness, tests) + +Three independent reviewers ran against the finished module. What they changed: + +- **Adoption no longer trusts the reservation alone.** The reservation proves + ownership only while it outlives the sets it guards, and it does not: it lives + in our store, the packs live at Telegram, and the in-memory backend is + selected silently whenever `MONGO_URL` is unset. After any wipe the original + takeover was reachable again. `createOrAdopt` now refuses to adopt when *this* + invocation first claimed the name — a genuine interrupted attempt always finds + its own reservation waiting, so there are no false negatives. +- **Post-action cleanup reads moved onto the detached context.** They wrote via + `commitContext` but read on the request context, so a cancelled request failed + the read and skipped the release while still deleting the pack record — + stranding a name permanently. Found independently by two reviewers. +- **Emoji clustering.** Nine valid emoji were refused outright; tag-sequence + flags shattered; and three inputs (trailing ZWJ, a joiner before a flag, an + odd regional-indicator count) passed validation and would have sent an + `emoji_list` Telegram rejects. +- **A reply tail is now reserved** from the handler budget via the existing + `chathelper.FetchContext`, which every other data-fetching module already + used. A slow photo `/newpack` could spend the whole 10s before Telegram was + called, and then send its error reply on a dead context — the user saw nothing. +- **The compression ladder resamples the scaled image, not the source.** It was + paying a full-size resample per rung: measured 1.99s versus 74ms for the three + rungs, on a dispatcher that runs handlers one at a time. +- **Tests.** Mutation testing refuted the previous round's non-vacuity claim in + three places. The `created`-flag release machinery had no coverage in either + direction; two emoji assertions passed against a handler that never called + Telegram; and nothing pinned module registration — the whole module could be + removed from `factories()` with the suite still green. + +Left open deliberately: the shutdown path never joins the polling goroutine, so +`context.WithoutCancel` does not actually survive process exit. That is +`cmd/server/main.go`, outside this module and affecting every module's commits. + +### 2026-08-25 — one pack per user + +The accepted scope originally chose "named packs, multiple per user, addressed by slug +argument". The user revised it to one pack per user, with every command operating on that +pack implicitly. + +Removed by the revision: + +- `<pack>` arguments on `/addsticker`, `/renamepack`, and `/delpack` +- `/packlist` (replaced by `/mypack`, singular) and its `List` + per-pack `Get` fan-out +- `maxPacksPerUser` as a tunable — the limit is one +- The separate default-pack command and per-user prefs record that a multi-pack default + would have required + +Red-team findings this revision resolves outright rather than mitigates: + +- Finding 4 (`/packlist` unbounded: 60s client timeout x 10 calls plus an N+1) — `/mypack` + is a single `Get` and makes no API calls at all +- Former open question O2 (`maxPacksPerUser` sizing) — no longer a choice + +Unaffected: Phase 1 (panic barrier, test harness) and Phase 5 (photo pipeline, token-leak +sentinel) need no changes. The write-ahead intent machinery, anonymous-sender rule, +`handlerTimeout`, and bot-rename resilience all carry over unchanged. + +Trade-off accepted: a user who wants a memes pack and a reactions pack separately cannot +have both (R10). + +### 2026-08-25 — `/delpack` as the URL-change path + +`/delpack` was reviewed as a destructive convenience. It is in fact the **only** mechanism for +changing a pack's URL, because Telegram exposes no rename-short-name method. That reframing +changed three things without adding a command: + +- `/renamepack`'s reply now names the delete-and-recreate route instead of only stating that + the link cannot change — turning a dead end into an answer. +- `/delpack`'s confirm prompt must state the pack title, the sticker count being destroyed, the + exact link being surrendered, and that both are permanent. It is the last point at which a + user learns stickers do not survive a URL change. +- A `/repack <newslug>` migration command was considered and **rejected for this plan**: copying + a full pack is up to ~121 sequential API calls, which blows `handlerTimeout` and stalls the + bot for every user under C1. It is viable only after Phase 5's goroutine offload lands, and is + recorded here as a follow-up rather than scoped in. + +The @Stickers command set was mapped exhaustively against the one-pack model; all nine of our +commands are either direct adaptations or (for `/mypack`) a justified addition, and every +dropped @Stickers command has a stated reason. + +**Bug found during this pass** (Phase 3, `/newpack`): the different-slug pending branch +overwrote the pending record unconditionally, which permanently orphans a set that was created +before an interruption. It now probes `GetStickerSet(oldName)` first and adopts rather than +overwrites when the old set exists. + + +### 2026-08-25 — post-implementation review: global slug reservation + +An independent review of the implemented module found a **pack takeover** in +`/newpack`, and it traces back to this plan, not only to the code. + +Phase 3 step 5 said: `GetStickerSet` succeeds → "we hold a `Pending` record for +this owner and slug, so this is our own interrupted attempt: adopt it". The +plan's own risk section stated the supporting invariant as *"a `Pending` record +for an owner means that owner asked for that name"*. That is true and **not +sufficient**. Asking is not creating. `PutVersioned` is create-only per *owner +key* (`packKey` is the owner ID alone); nothing reserved a name globally. So a +user with no pack who typed another user's slug produced byte-identical evidence +to a genuine resumed attempt — and adopted their pack, then could `/delpack` it. +Every share link is public, so slugs are trivially enumerable. + +The plan pre-authorised one response to this signal ("disable adoption — a +one-line change"), which would have closed the hole by dropping the +"an interrupted `/newpack` can be completed by re-running" success criterion. +The user chose the stronger fix instead: + +**A global slug reservation.** `SlugReservation{Slug, OwnerID}` is written +create-only under `slug:<slug>` *before* Telegram is touched. Adoption is +allowed only when the reservation names the caller. The first claimant of a name +is the only user who can ever adopt a set under it, so "this set is mine" became +a proven fact rather than an assumption — and both success criteria survive. + +Consequent changes: + +- `reserveSlug` runs before `claimSlug`; a name held by anyone else replies + "that pack name is taken" with **zero** API calls. +- `resolveStaleIntent` re-proves the *old* slug's reservation before adopting + under it, and releases it on a positive "no such set". +- `dropIntent` no longer fires on unknown errors (was a plan rule 4 violation in + its own right): on anything but a *classified* refusal both the intent and the + reservation survive, which is what lets a re-run recover. Only a positive + refusal releases them. +- `/delpack` keeps the reservation, so a deleted name stays recoverable by its + owner and unavailable to everyone else — which also matches Telegram's likely + behaviour for deleted short names (R11). + +Four further defects fixed in the same pass: + +- **A stale `/delpack` confirmation deleted a live pack's record.** Pending + actions used a random key per invocation, so two could be live at once, and + the delete path cleared the record *by owner* without checking it still named + the set being deleted — precisely the documented delete-then-recreate URL + change. Now keyed per user (matching `stock/pending_dividend.go`), superseded + by a newer prompt, and gated on `ownsSet` before clearing. +- **Unbounded storage from a public command.** The same random key meant a user + who ran `/delpack` and never tapped left a permanent document. +- **The panic barrier missed the detached hook goroutine**, so a panicking + `CommandHook` (the `stats` module ships one) still killed the process. C9's + "Phase 1 closes this" was true for handlers only. +- `/delpack`'s result message bypassed `chathelper.Reply`, so in a forum + supergroup it landed in General instead of the topic. + +### 2026-08-25 — Phase 4 review pass + +Ten findings applied before implementation began. Six changed what gets written: + +- `resolveSource` takes `(ctx, b, ownerID, msg)` from the start, and `photoRef` is gone — the + photo branch resolves to a `file_id` like every other path, so Phase 5 adds a branch instead + of rewriting call sites. +- The static-only gate moves onto `resolveSource`, the path that can actually receive a + non-static sticker, and now also rejects `Type != "regular"` — a mask sticker is static yet + invalid for a regular set, which `IsAnimated || IsVideo` does not catch. +- `/addsticker` gained its missing no-pack path, and the note on why its refusal is + deliberately *not* the uniform `resolveOwned` one. +- `/delsticker` takes the same per-user keylock as `/addsticker`; both do the same + read-modify-write on `Count`. +- `/delsticker`'s "never deletes the record" criterion was scoped to transient and unknown + errors. Unqualified, it forbade Phase 3's `STICKERSET_INVALID` self-heal — the very + mechanism that unwedges a user whose set Telegram removed. +- The animated/video check moved ahead of the store read in `resolveOwned`'s numbered gate. + +Also: the `Count: 0` aftermath now names its recovery route, `STICKERS_TOO_MUCH` and the +no-pack paths gained success criteria, R12 records the unverified `file_id`-reuse premise, and +Phase 6 must document `<name...>` in the conventions doc rather than shipping an undocumented +parameter form. + +#### Consistency sweep — one-pack revision + +- Files reread: plan.md and all six phase files. +- Deltas checked: 9 (`<pack>` dropped from `/addsticker`, `/renamepack`, `/delpack`; + `/packlist` -> `/mypack`; `listPacks` -> `getPack`; key `<ownerID>:<slug>` -> `<ownerID>`; + `maxPacksPerUser` removed; `matchPack` -> `ownsSet`; `buildSetName` -> `makeSetName`). +- Stale references reconciled: 4 (incl. the frontmatter description, which said + "multi-pack-per-user" and is surfaced by every `ak plan list`). Two live risk signals in the R2/R7 rows still named + `/packlist`; Phase 5's `/setpackicon` still said "one of the caller's packs". Phase 5 was + therefore **not** untouched, contrary to the initial assessment. +- Remaining `/packlist`, `listPacks`, and `maxPacksPerUser` mentions are all deliberate + comparative or historical text ("this replaced X", the revision log, the red-team table). +- Phases 1 and 5 confirmed free of multi-pack phrasing after the fix. +- Unresolved contradictions: 0 + +## Red Team Review + +### Session — 2026-08-25 +**Findings:** 28 raw from 3 reviewers → 16 after dedup (16 accepted, 0 rejected) +**Severity breakdown:** 7 Critical, 6 High, 3 Medium +**Reviewers:** Security Adversary (Fact Checker), Failure Mode Analyst (Flow Tracer), +Assumption Destroyer (Scope Auditor). All findings carried `file:line` evidence, so none +were filtered. Four of the seven Critical findings were independently corroborated by all +three reviewers. + +| # | Finding | Severity | Disposition | Applied To | +|---|---------|----------|-------------|------------| +| 1 | Empty `MODULES` loads all modules; "opt-in" false in 3 places | Critical | Accept | C10, Phase 6, Goals | +| 2 | No `recover()` on the update path; a handler panic kills the process | Critical | Accept | C9, Phase 1 | +| 3 | Bot token leaks via `*url.Error` into the dispatcher log | Critical | Accept | Rule 5, R5, Phase 5 | +| 4 | `/packlist` unbounded: 60s client timeout × 10 calls + N+1 | Critical | Accept | C2, Rule 1, Phase 3 | +| 5 | `/delsticker` probe deletes the record on any error | Critical | Accept | Rule 4, R7, Phase 4 | +| 6 | `/delpack` button is a permanent replayable delete capability | Critical | Accept | Phase 3 | +| 7 | `RecordingBot` cannot return structs; 3 phases untestable | Critical | Accept | C11, Phase 1 | +| 8 | Anonymous group admins share one `From.ID` | High | Accept | Rule 3, R6 | +| 9 | Bot rename orphans every pack; `Pack.Name` written never read | High | Accept | R8, Phase 2, Phase 4 | +| 10 | `GetStickerSet` not-found is an undefined branch | High | Accept | Rule 4, Phase 3 | +| 11 | Commit writes on `rootCtx`; deploy is the normal orphan path | High | Accept | Rule 2, R2, Phase 3 | +| 12 | `/help` 4096-rune ceiling; 884 runes headroom measured | High | Accept | Phase 6 | +| 13 | C5's no-adoption conclusion not forced by its premise | High | Accept | C5, R2, Phase 3 | +| 14 | `Put` used where create-only `PutVersioned` exists | Medium | Accept | Phase 3 | +| 15 | `parseSlug` case-preserving value reaches the storage key | Medium | Accept | Folded into #9 | +| 16 | `/newpack` oracle contradicts the plan's own no-disclosure claim | Medium | Accept | Accepted disclosure section | + +**User decisions taken during adjudication:** explicit `MODULES` before Phase 6; panic +barrier in `modules.Install` as Phase 1; module-wide deadline plus persisted counts; +write-ahead intent record for orphan recovery. + +### Whole-Plan Consistency Sweep +- Files reread: plan.md, phase-01-shared-prerequisites.md, phase-02-store-setname-emoji.md, + phase-03-pack-lifecycle.md, phase-04-sticker-commands.md, phase-05-photo-pipeline.md, + phase-06-wiring-docs.md +- Decision deltas checked: 14 (phase renumber 1-5 -> 1-6; `parseSlug` -> `matchPack`; + `/delsticker` probe removed; `/packlist` API-free via `Pack.Count`; `Pack.Pending` + write-ahead intent; callback payload slug -> opaque id; C5 rewritten; opt-in claims + removed; `handlerTimeout` cross-cutting rule; `senderID` rule; `isStickerSetMissing`; + download sentinel; slug cap decoupled from C3; `Put` -> `PutVersioned` for create) +- Reconciled stale references: 0 remaining. `parseSlug` survives only in phase-02 as the + named superseded design and in the red-team table as finding #15 — both deliberate + historical references, not live claims. "opt-in" survives only in C10's negative heading + and the finding that corrected it. +- Link integrity: all 6 phase links resolve; no orphan phase files. +- Cross-phase references verified consistent under the new numbering. +- Unresolved contradictions: 0 + +<!-- slug: sticker-pack-module --> diff --git a/plans/reports/correctness-review-260825-1515-sticker-module.md b/plans/reports/correctness-review-260825-1515-sticker-module.md new file mode 100644 index 0000000..d699976 --- /dev/null +++ b/plans/reports/correctness-review-260825-1515-sticker-module.md @@ -0,0 +1,423 @@ +# Correctness / Crash-safety / Concurrency Review — internal/modules/sticker + +Date: 2026-08-25 · Reviewer lens: correctness, crash-safety, concurrency. +Out of scope by assignment: cross-user security impact, test quality. +Scope: uncommitted `internal/modules/sticker/` (~1.9k LOC non-test) plus the +modified `cmd/server/main.go`, `internal/modules/dispatcher.go`, and the +storage/keylock contracts they rely on. + +`go build ./...` clean. `go vet` clean on sticker/storage/modules. +`go test ./internal/modules/sticker/` passes. + +## Verdict + +The write-ahead intent state machine is sound. Every /newpack interruption point +recovers or refuses; none wedges the user permanently on paths reachable in this +deployment. Two real findings (H-1, H-2) undermine the *durability* half of the +design rather than its logic. The rest is MEDIUM/LOW. + +--- + +## Findings + +### H-1 (HIGH) — `commitContext`'s SIGTERM protection is defeated by the shutdown path + +`cmd/server/main.go:214-228`, `internal/modules/sticker/state.go:53-59` + +`commitContext` uses `context.WithoutCancel(ctx)` + 5s so a post-action commit +survives SIGTERM. That only defends against *context* cancellation. The process +does not wait for it: + +``` +go func() { b.Start(rootCtx) }() // main.go:214 — return value never awaited +<-rootCtx.Done() // main.go:220 +srv.Shutdown(shutdownCtx) // HTTP only +} // main returns -> defer closeProvider() -> process exits +``` + +`b.Start` *would* drain correctly — with `WithNotAsyncHandlers` + 1 worker +(`telegram/client.go:28`, lib `defaultWorkers = 1`) its `wg.Wait()` blocks until +the inline handler returns — but `main` never joins that goroutine. On SIGTERM +`main` proceeds as soon as `srv.Shutdown` finishes (immediate with no in-flight +HTTP), runs `defer closeProvider()` (main.go:125), and exits. The detached +commit gets milliseconds, then the Mongo client is disconnected under it. + +Failure scenario: deploy lands while user runs `/newpack foo Bar`. +`CreateNewStickerSet` returns 200; `finishNewPack` → `commitPack` → detached +`Put` starts; SIGTERM arrives; process exits before the write lands. Record stays +`Pending:true`. Recoverable (re-run `/newpack foo …` adopts), so not data loss — +but the write-ahead recovery path is exercised routinely rather than rarely, and +the comment's claim ("must not be lost because the process is shutting down") is +false as built. + +Same exit kills `adjustCount`'s commit (count silently under-counts, never +self-corrects) and `renamePack`'s commit (stored title diverges from Telegram +permanently). + +Fix direction: have `main` wait for the polling goroutine before returning — +e.g. `pollDone := make(chan struct{}); go func(){ b.Start(rootCtx); close(pollDone) }()` +then `select { case <-pollDone: case <-time.After(shutdownGrace): }` before +`srv.Shutdown` / `closeProvider`. Grace must exceed `commitTimeout` (5s). + +### H-2 (HIGH) — every reply is sent on the same exhausted context the API work drained + +`internal/modules/sticker/state.go:62-65` and all nine handlers. + +`handlerContext` = 10s for the whole handler. Nothing reserves a tail for the +reply. `chathelper.Reply` sends with that same ctx, so once the budget is spent +the user gets **no message at all** — success or failure. + +Concrete: `/newpack mypack My Pack` replying to a **photo**. +`resolveSource` → `downloadFile` (own 8s client timeout, but also bounded by the +handler ctx) → `toStickerPNG` (CatmullRom on up to 4096×4096) → `UploadStickerFile`. +On a slow link that is 7-9s. `CreateNewStickerSet` then runs on <1-3s and +returns `context.DeadlineExceeded` — which is correctly *not* `createRefused`, +so intent + reservation are kept — and `replyAPIError` then calls +`reply(ctx, …)` on the dead context. User sees silence, has a `Pending` record +and a burned reservation, and no indication what happened. `/mypack` does show +the pending marker, so it is discoverable, not wedged. + +The repo already has the fix pattern and this module is the only one not using +it: `chathelper.FetchContext` reserves a 3s reply tail and is used by +`coin/views.go:35`, `gold/handlers.go:29,185`, `stock/stock_events.go:78`, +`monkeyd/tags_command.go:81`. + +Fix direction: derive `fetchCtx, cancel := chathelper.FetchContext(ctx)` for the +download/upload/Telegram calls and keep the outer `ctx` for `reply`. + +### M-1 (MEDIUM) — post-action cleanup helpers *read* on the cancellable context + +`pack_handlers.go:179` (`releaseSlug`), `:397` (`adjustCount`), `:432` +(`dropPackRecordIfSet`), `:464` (`dropPackRecord`). + +Each of these runs *after* a confirmed Telegram-side action, and each carefully +wraps its **write** in `commitContext` — but performs the **read** it depends on +with the caller's cancellable `ctx`. A cancelled/expired ctx therefore silently +skips the write. + +- `adjustCount:397` — ctx expired right after a successful `AddStickerToSet` → + `getPack` fails → count increment never persisted. The delta is lost forever + (the next adjust reads the stale base). Handler falls back to an in-memory + `pack.Count++` purely for the reply, so the user is told a number that was + never stored. +- `releaseSlug:179` — ctx expired in the `createRefused` branch + (`pack_handlers.go:351-354`) → reservation read fails → name stays reserved + with no pack behind it. The owner can re-reserve (owner check passes), so the + loss is only to other users' namespace, but it is permanent. +- `dropPackRecord:464` — read fails → record is still deleted (correct: keeping + it would block `/newpack`) but the slug can never be freed, because the record + was the only thing that knew the name. + +Fix direction: derive the commit context once at the top of each helper and use +it for both the read and the write. + +### M-2 (MEDIUM) — emoji: nine valid RGI emoji are refused outright + +`emoji.go:130-156` (`isEmojiRune`). Verified by running `parseEmoji` against +each codepoint: + +| Input | Result | +|---|---| +| `©️` U+00A9, `®️` U+00AE | refused | +| `〰️` U+3030, `〽️` U+303D | refused | +| `㊗️` U+3297, `㊙️` U+3299 | refused | +| `Ⓜ️` U+24C2 | refused | +| `⤴️` U+2934, `⤵️` U+2935 | refused | + +All are in Telegram's emoji keyboard. `™️` U+2122 and `ℹ️` U+2139 are special-cased +but their neighbours are not. `/editsticker ©️` fails with "is not an emoji". + +Fix direction: add U+00A9, U+00AE, U+2934-2935, U+3030, U+303D, U+3297, U+3299, +U+24C2 to the singleton/range list (2900-297F would also cover the arrows). + +### M-3 (MEDIUM) — emoji: tag-sequence flags shatter, and the refusal prints raw tag characters + +`emoji.go:69-102`. `isBinding` covers Mn/Me but tag characters (U+E0020-E007F) +are category **Cf**, so `🏴󠁧󠁢󠁥󠁮󠁧󠁿` (England/Scotland/Wales flags) splits into +`["🏴", "\U000e0067", "\U000e0062", …]`. Verified output: + +``` +in="🏴\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f" +clusters=["🏴" "\U000e0067" … ] err="\U000e0067" is not an emoji. +``` + +Two problems: a legitimate emoji is rejected, and `%q` renders an invisible tag +char, so the user is told `"\U000e0067" is not an emoji`. + +Fix direction: treat U+E0020-U+E007F as binding, terminating the cluster at +U+E007F (cancel tag). + +### M-4 (MEDIUM) — emoji: three inputs pass validation and send an invalid `emoji_list` to Telegram + +`emoji.go:69-102`, `:117-128`. Verified: + +| Input | Clusters produced | Sent to Telegram | +|---|---|---| +| `😀‍` (trailing ZWJ) | `["😀‍"]` | yes → `STICKER_EMOJI_INVALID` | +| `😀‍🇻🇳` | `["😀‍🇻", "🇳"]` — the ZWJ branch (`emoji.go:83-88`) swallows the first regional indicator, orphaning the second | yes | +| `🇻🇳🇺` (odd RI count) | `["🇻🇳", "🇺"]` — lone RI passes `isEmojiRune` via `emoji.go:154` | yes | + +Not a crash: `apiRefusal` maps `STICKER_EMOJI_INVALID` to "Telegram rejected +those emoji", so the user gets a sane message after one wasted API round-trip. +Correctness bug, low impact. + +Fix direction: reject a cluster ending in ZWJ; do not classify a lone regional +indicator as emoji; do not let the ZWJ branch consume a regional indicator. + +### L-1 (LOW) — `/newpack` does all its expensive work before checking whether the caller already has a pack + +`pack_handlers.go:68-99`. `resolveSource` (photo path: GetFile + up to 2 MB +download + resize + `UploadStickerFile`) and `resolver.resolve` (GetMe) run +*before* the lock and before the "you already have a pack" pre-check. A user who +already owns a pack pays a full download+upload and creates a file on Telegram's +servers, then is refused. Wasted work only; no state divergence. Moving the +pre-check above `resolveSource` would also buy back budget for H-2 — but note +the pre-check must stay *after* `lockUser` and *before* `reserveSlug`, which is +the ordering the comment at `:85-91` is defending. + +### L-2 (LOW) — `dropPackRecord` is called both inside and outside `lockUser` + +Inside: `handleAddSticker` (`sticker_handlers.go:80`), `handleDelSticker` (`:129`), +`handleRenamePack` (`pack_handlers.go:544`). +Outside: `handleEditSticker` (`sticker_handlers.go:172`), `handleOrderSticker` +(`:210`), `handleSetPackIcon` (`setpackicon.go:45`). + +`dropPackRecord` is a read → delete → release-slug sequence. Unlocked call sites +could delete a record another handler just committed. + +**Not reachable in production today**: dispatch is inline with one worker +(`bot.WithNotAsyncHandlers()`, `defaultWorkers = 1`), the sticker module +registers no cron job, and the dispatcher's detached per-command stats hook +(`dispatcher.go:80-88`) writes only to the `stats` collection. Latent only — +flagging because the inconsistency reads as an oversight rather than a decision. + +Same class, same reachability: `handleAddSticker` resolves `pack` at +`sticker_handlers.go:41` *before* `defer s.lockUser(...)()` at `:66`, then uses +`pack.Name` for the API call. `adjustCount` correctly re-reads under the lock, +but the API call itself uses the pre-lock value. `handleRenamePack` likewise +reads at `pack_handlers.go:531` and `Put`s that whole stale record at `:550`, +which would clobber a concurrent `Count` change. + +### L-3 (LOW) — `/ordersticker` reports a position Telegram may not have honoured + +`sticker_handlers.go:196-222`. Upper bound is deliberately delegated to Telegram +(correct — a local `Count` is advisory). But if `SetStickerPositionInSet` clamps +an out-of-range position instead of erroring, the reply "Moved to position N" +states something false. Consider "Moved." or re-reading the set. + +### L-4 (LOW) — `photoFileID` picks by `FileSize`, which may be absent + +`photo.go:57-63`. `PhotoSize.FileSize` is optional in the Bot API. If Telegram +omits it for every size, all compare equal to 0 and `Photo[0]` — the *smallest* +thumbnail — is chosen, yielding a blurry sticker. Telegram populates it in +practice. Tie-break on `Width*Height` instead. + +### L-5 (LOW) — state divergence on a DB reset / collection drop + +`pack_handlers.go:313-320`. `createOrAdopt`'s `err == nil` branch adopts any +existing set under `<slug>_by_<bot>`, and its safety rests entirely on the +reservation table being authoritative. If the module's collection is dropped or +the same bot token is pointed at a fresh Mongo, the reservations vanish while the +Telegram sets do not: the next claimant of a previously-used slug reserves it +cleanly (`created == true`), then adopts the *previous* owner's set, and the +record's `Count = 1` (`:363-365`) will disagree with the real set. + +The cross-user impact belongs to the security reviewer; noting it here only as a +Count/ownership divergence and an operational constraint. Fix direction is +operational, not code: never drop this collection while packs exist, or gate +adoption on `created == false`. + +### Nit — `apiRefusal` hardcodes `120` instead of `maxStickersPerPack` + +`errors.go:76` says "Your pack is full (120 stickers)." while +`sticker_handlers.go:17` defines the const. The const is otherwise referenced +only from tests. + +--- + +## /newpack interruption-point table + +Notation: **R** = slug reservation (`slug:<slug>`), **P** = Pack record. +"Next `/newpack <same slug>`" and "Next `/newpack <other slug>`" are the two +recovery entry points. All rows traced against `handleNewPack` +(`pack_handlers.go:45-118`) and its four helpers. + +| # | Interruption point | State left behind | Next `/newpack` **same** slug | Next `/newpack` **different** slug | Wedged? | +|---|---|---|---|---|---| +| 1 | Before `reserveSlug` (arg/slug/title/source/GetMe failure, `:50-81`) | none | normal create | normal create | no | +| 2 | Between pre-check (`:92`) and `reserveSlug` write | none | normal create | normal create | no | +| 3 | After R written, before `claimSlug` (`:101-106`) | R only | `reserveSlug` conflict → own → resume (`created=false`), `claimSlug` creates P, create proceeds | R(old) orphaned; new R created; P created for new slug. Old R leaks (owner-held, re-reservable by owner) | no | +| 4 | `claimSlug` fails/answers with `created=true` (`:106-115`) | R released at `:112` | normal create | normal create | no | +| 5 | After P(pending) written, before `GetStickerSet` (`:106→313`) | R + P(pending) | `claimSlug` → `existing.Slug == slug` → resume → `GetStickerSet` missing → create | `resolveStaleIntent` (`:253`): R(old) held by caller, `GetStickerSet(old)` **missing** → `Put(new intent)`, `releaseSlug(old)` → create | no | +| 6 | `GetStickerSet` returns unknown error (`:325-330`) | R + P(pending) unchanged — **deliberately untouched** | retry; succeeds once Telegram answers | as row 5 | no | +| 7 | Between `GetStickerSet`(missing) and `CreateNewStickerSet` (`:337`) | R + P(pending) | resume → create | as row 5 | no | +| 8 | `CreateNewStickerSet` returns a `createRefused` code (`:351-354`) | R released, P dropped | clean retry (same refusal until the cause changes) | clean create | no | +| 9 | `CreateNewStickerSet` returns a non-refusal error (timeout/429/SIGTERM) — **set may or may not exist** (`:355`) | R + P(pending) kept | `GetStickerSet` decides: exists → adopt + commit; missing → create. Both correct | `resolveStaleIntent` probes old name: exists → **adopt old**, tell user "restored"; missing → release old, create new | no | +| 10 | Create succeeded server-side, process dies before `finishNewPack` | R + P(pending); set exists | `GetStickerSet` → exists → `finishNewPack(adopted=true)`, `Count=1` | `resolveStaleIntent` → old set exists → adopt, refuse the new name with "restored" | no | +| 11 | `commitPack` inside `finishNewPack` fails (`:366-369`) | R + P(pending); set exists | as row 10 → adopt + commit | as row 10 | no | +| 12 | Process exits during the detached `commitPack` (**H-1**) | identical to row 11 | as row 10 | as row 10 | no | +| 13 | `resolveStaleIntent` dies between `Put(new intent)` (`:296`) and `releaseSlug(old)` (`:300`) | R(old) orphaned + R(new) + P(new, pending) | resume new slug → create | probes new slug's set | no; old R leaks permanently to other users | +| 14 | P(pending) exists but its R is now held by someone else | P(pending) + foreign R | `claimSlug` → `existing.Slug == slug` → resume → `GetStickerSet` → **if the other holder created it, this adopts their set** | `resolveStaleIntent` → `held.OwnerID != caller` → drop dead intent, proceed cleanly | see L-5 | + +Row 14 is only reachable via the L-5 reservation-loss scenario; in normal +operation `reserveSlug` runs before `claimSlug`, so a pending P always implies +the caller held R at the moment P was written, and no code path transfers R +between users (`releaseSlug:187` re-verifies ownership). + +**Escape hatch verified**: `handleDelPack` (`delpack_callback.go:22`) does *not* +require `!Pending`, so a user stuck on a pending record can always clear it. +`DeleteStickerSet` on a never-created set returns `STICKERSET_INVALID`, which +`isStickerSetMissing` treats as success (`:180`), dropping P and releasing R. + +--- + +## Verified sound + +Checked and found correct; no action needed. + +**State machine / ordering** +- Pre-check "already has a finished pack" is inside `lockUser` and before + `reserveSlug` (`:83-99`). Reversing those two is the name-burning primitive the + comment describes; the order is right. +- `claimSlug` uses `PutVersioned(…, 0, …)` (create-only), never `Put`. Both + backends give exactly one winner: Mongo via the version-0/absent filter + + upsert + `_id` duplicate-key (`mongo_doc_store.go:85-105`); memory via + `ErrConflict` when the key exists (`memory_provider.go:107-110`). +- `created` is threaded correctly: `reserveSlug` returns `false` when it merely + resumes an existing reservation (`:165`), so the bail path at `:111` never + releases a reservation predating the invocation. +- `releaseSlug` re-verifies `held.OwnerID == ownerID` inside the operation + (`:187`), not at the call site. +- `dropPackRecord` reads before deleting so the slug is still known (`:464-480`), + and deletes the record before releasing the name — the safe ordering (the + reverse would free a name while a record still claims it). +- `dropPackRecordIfSet` guards on `ownsSet` (`:440`) so a stale `/delpack` + confirmation cannot erase a newer pack's record. +- `resolveStaleIntent` re-proves reservation ownership rather than inferring it + from the pending record (`:258-272`). + +**Error classification** +- `isStickerSetMissing` requires both `bot.ErrorBadRequest` **and** the + `STICKERSET_INVALID` substring (`errors.go:43-46`). `context.DeadlineExceeded`, + `context.Canceled`, 429, and transport errors all fall through — they never + authorise a record delete. Verified at all six call sites. +- `createRefused` (`errors.go:113-123`) lists only request-validation codes and + is kept separate from `apiRefusal` despite the overlap. Every code listed + genuinely proves nothing was created. +- No path infers absence from a generic failure. `createOrAdopt`'s `default` + branch (`:325-330`) and `resolveStaleIntent`'s (`:303-307`) both change nothing. + +**Count** +- `adjustCount` re-reads inside `lockUser` (`:397`) rather than trusting the + handler's pre-lock copy. +- Clamped at 0 (`:407-410`); cannot go negative. +- Missing record returns `storage.ErrNotFound` and does **not** recreate the + record (`:402-405`). +- A failed `AddStickerToSet`/`DeleteStickerFromSet` returns before `adjustCount`, + so a failed API call never moves the count. +- Pack-full (`STICKERS_TOO_MUCH`) returns via `replyAPIError` with no increment. + +**Context** +- `commitContext` used at every post-action commit: `commitPack:418`, + `dropIntent:381`, `dropPackRecord:471`, `releaseSlug:191`, + `dropPendingDelete:~200`, and the `/delpack` prompt persist. +- Not used where cancellation should apply: `reserveSlug`'s and `claimSlug`'s + pre-action writes, `resolveStaleIntent`'s intent replacement, and the pending + action consume in the callback all use the cancellable ctx. Correct. +- Every `context.WithTimeout` has a matching `cancel`, all `defer`red. No leaks; + `go vet` agrees. + +**Concurrency** +- `keylock.Map` zero value is usable; `defer s.lockUser(id)()` acquires eagerly + and defers only the unlock — correct idiom at all six call sites. +- `state`'s zero-value `usernameResolver` and `nowFn == nil` are both safe + (`state.go:47-51`, `setname.go:97-122`), so `New` not initialising them is fine. +- `usernameResolver.resolve` never caches a failure and holds no lock across the + `GetMe` call. +- Slug races between two *different* users are resolved by store atomicity, not + by `keylock` (which is per-user and could not help). Correct choice. +- `/delpack` double-press: the pending action is consumed *before* + `DeleteStickerSet` (`delpack_callback.go:~183`), so a second press finds + `ErrNotFound`. Serialized dispatch makes it moot anyway. + +**Callback safety** +- `models.CallbackQuery.Message` is a **value** (`MaybeInaccessibleMessage`), not + a pointer, in `go-telegram/bot v1.20.0`. `query.Message.Message` cannot nil-deref; + the inner `*Message` nil check is the right and sufficient guard. +- Binding check (`chat + message id + non-zero MessageID`) precedes every side + effect including `clearButton`. +- `parseDeleteCallback` bounds length to 64 and validates hex before use; the id + is a lookup key only, never an authorisation input. + +**Emoji (correct cases, verified by execution)** +ZWJ families `👨‍👩‍👧‍👦`; skin tone + ZWJ `👩🏽‍🚀`; VS16-then-ZWJ `🏳️‍🌈`; +ZWJ-then-VS16 `🏴‍☠️`; keycaps `1️⃣` `#️⃣`; regional-indicator pairs `🇻🇳`; +skin-tone modifiers `👍🏿`; `⭐` (the default emoji) classifies as emoji; +plain `A` is refused. `len(out) > 20` boundary matches Telegram's 1-20. +`parseEmoji` returns `(nil, nil)` for empty input and `/addsticker` falls back +correctly while `/editsticker` refuses — the right asymmetry. + +**Image pipeline** +- `decodeBounded` checks `DecodeConfig` before allocating pixels; rejects + >4096 per side and `<=0` dimensions as a `userError`. +- `scaleToLongEdge` clamps the short edge to ≥1, so 4096×1 → 512×1: no + zero-dimension image, no divide-by-zero. +- Compression ladder is a fixed 3-element slice — cannot loop forever. The + `data, err =` reassignment at `image.go:64` clobbers `data` on encode error, + but the loop unconditionally reassigns it afterwards, and a loop encode error + returns `(nil, encErr)`. No nil-with-nil-error return. +- `toThumbnailPNG` offsets are always ≥0 because both scaled dimensions are ≤100; + `draw.Draw`'s `sp` correctly maps `scaled.Bounds().Min`. +- `downloadFile` bounds by `maxSourceBytes+1` on the reader itself rather than + trusting `Content-Length`, and closes the body. + +**Boundaries** +- `slugRe` `^[a-z][a-z0-9_]{2,39}$` = 3-40 chars, matching `minSlugLen`/`maxSlugLen`; + `__` and trailing `_` are checked separately as the comment states. +- `makeSetName`'s budget cannot go negative for any legal Telegram username (≤32 + chars → suffix 36 → budget 28). +- No `List` calls anywhere in the module; every lookup is a keyed `Get`. No N+1. +- `senderID` rejects `SenderChat != nil`, so anonymous group admins cannot all + collapse onto `GroupAnonymousBot`'s single user id. + +**Error surfacing** +- `replyErr` shows `userError` verbatim and replaces everything else with + `genericFailure`; `downloadFile` discards the original error entirely rather + than wrapping, so the bot token in `FileDownloadLink` cannot reach a log via + `errors.Unwrap`/`%v`. `classify` inspects only error *types*. +- No path returns `nil` where the caller assumes success. The two "log and + continue" spots (`renamePack` commit `:550-554`, `adjustCount` fallback + `sticker_handlers.go:87-92`, `:135-141`) both follow a *confirmed* Telegram + success, so reporting success to the user is accurate about the pack even + though the stored count/title may lag. + +--- + +## Recommended actions + +1. **H-1** — join the polling goroutine in `main` with a grace period > 5s before + `srv.Shutdown`/`closeProvider`. Without this the whole `commitContext` design + is decorative. +2. **H-2** — adopt `chathelper.FetchContext` in the sticker handlers so a slow + photo pipeline cannot swallow the user's reply. +3. **M-1** — use the commit context for the *read* as well in `adjustCount`, + `releaseSlug`, `dropPackRecord`, `dropPackRecordIfSet`. +4. **M-2/M-3/M-4** — emoji table additions, tag-sequence binding, and the three + invalid-cluster rejections. +5. **L-1** — move the "already have a pack" pre-check above `resolveSource` + (keeping it inside `lockUser` and before `reserveSlug`). +6. **L-2** — make `lockUser` coverage uniform across the six `dropPackRecord` + call sites, and take the lock before the `getPack` whose value feeds the API + call in `handleAddSticker`/`handleRenamePack`. +7. L-3, L-4, L-5, nit — at author's discretion. + +## Unresolved questions + +1. Does Telegram permanently reserve a deleted set's short name? The code + (`pack_handlers.go:455-458`) documents this as unverified and degrades + gracefully either way, so it is not blocking — but it decides whether + `releaseSlug` after a `/delpack` is meaningful or purely local bookkeeping. +2. Does `SetStickerPositionInSet` error or clamp on an out-of-range position? + Determines whether L-3 is a false success message or a non-issue. +3. Is the sticker collection ever dropped or re-pointed in this deployment's + operational runbook? That is the sole trigger for L-5 / table row 14. diff --git a/plans/reports/security-review-260825-1515-sticker-module.md b/plans/reports/security-review-260825-1515-sticker-module.md new file mode 100644 index 0000000..02c4ea3 --- /dev/null +++ b/plans/reports/security-review-260825-1515-sticker-module.md @@ -0,0 +1,234 @@ +# Security review — sticker module (uncommitted) + +Lens: security / abuse resistance only. Style, naming, test coverage out of scope. +Method: traced attacker-controlled inputs (command args, replied message, callback payload, +image bytes) through every store write and every Telegram call that names a set. Third pass, +after the takeover and name-burning fixes. + +Verified environment facts used below: +- `internal/telegram/client.go:26-31` — `WithNotAsyncHandlers()`, single worker: updates are + processed strictly one at a time, inline on the polling goroutine. +- `cmd/server/main.go:266-280` — provider auto-detect: `MONGO_URL` unset ⇒ **memory backend**, + announced with `log.Warn` only. `"sticker": sticker.New` is registered unconditionally + (`cmd/server/main.go:95`). +- `go-telegram/bot@v1.21.0/raw_request.go:78-81` — the library redacts the token inside + `*url.Error.URL` for API-call failures. +- `models.CallbackQuery.Message` is a **value** `MaybeInaccessibleMessage` holding `*Message`, + so `query.Message.Message` cannot nil-panic. + +--- + +## HIGH — adoption is authorised by a record that is less durable than the object it protects + +`internal/modules/sticker/pack_handlers.go:313-321` (adopt branch), `:138-166` (reserveSlug), +`cmd/server/main.go:266-280` (backend selection). + +The reservation is the *only* evidence that an existing Telegram set belongs to the caller — +`GetStickerSet` exposes no owner. The reservation lives in the module's store; the sticker set +lives on Telegram forever. Any event that empties the store while the sets survive re-opens the +exact cross-user takeover the reservation was added to close, with no code change. + +Exploitation (memory backend variant — reachable by omitting `MONGO_URL`, which only logs a Warn): + +1. Victim V: `/newpack cool My Pack` → set `cool_by_<bot>` created, share link is public by design. +2. Bot restarts (deploy, OOM, VM reboot). Memory store is empty; Telegram set untouched. +3. Attacker A (any user, no pack) replies to any sticker with `/newpack cool Whatever`. + - `reserveSlug` → no reservation exists → created for A. + - `claimSlug` → no pack record for A → pending intent written. + - `createOrAdopt` → `GetStickerSet("cool_by_<bot>")` returns **nil error** → + `finishNewPack(..., adopted=true)` → A's record now owns V's set. +4. A gains, via calls that carry no owner scoping at all: + - `/delpack` → `DeleteStickerSet{Name}` — **destroys V's pack permanently** (no user_id param). + - `/delsticker` replying to any sticker from V's public pack — `resolveOwned` passes because + `pack.Name == st.SetName`; `DeleteStickerFromSet{Sticker}` takes only the file id. + - `/renamepack` → `SetStickerSetTitle{Name,Title}` — also unscoped. + V is simultaneously locked out: V's `/newpack cool` answers `slugTaken`, `/mypack` says no pack. + +Same primitive without the memory backend: collection dropped, `MONGO_DATABASE` changed, module +renamed, or a Mongo restore from a backup older than the newest sets. + +Why the existing guards do not stop it: every guard (reservation owner check, `created` scoping, +`ownsSet`, uniform refusals) reasons entirely inside the local store. When the store is empty the +guards are all *satisfied*, and the adopt branch is by design the path that turns "a set exists +under a name I hold" into ownership. + +Fix direction (cheap and precise, no new state): adoption should require that the reservation +**pre-dated this invocation**. `reserveSlug` already computes exactly that as `created`; plumb it +into `createOrAdopt` and, when `created == true` and `GetStickerSet` succeeds, refuse with +`slugTaken` (plus release the just-made reservation) instead of adopting. A genuine interrupted +attempt always re-enters with `created == false` (its reservation was written by the earlier run), +and a set cannot exist for a reservation first written microseconds ago in this same handler — so +this has no false negatives, and the store-wipe path can no longer adopt anything. +Secondly: refuse to build/register this module on a non-durable provider (or `log.Fatal` when +`KV_PROVIDER=memory` and sticker is enabled) — the module creates permanent, globally visible +Telegram objects and must not run on a store documented as "data lost on restart". + +## MEDIUM — cleanup helpers read on the request context but write on a detached one; slugs leak permanently + +`internal/modules/sticker/pack_handlers.go:179` (`releaseSlug` → `getSlugReservation(ctx, …)`), +`:464` (`dropPackRecord` → `getPack(ctx, …)`), `:432` (`dropPackRecordIfSet` → `getPack(ctx, …)`). + +`commitContext` (`state.go:59-61`) exists precisely because SIGTERM cancels `rootCtx` mid-handler. +It is applied to the `Delete`/`Put` calls in these helpers but **not** to the reads that decide what +to delete. The reads therefore fail exactly in the situation the detached write was designed for. + +Scenario (ordinary deploy, no attacker needed): user presses the `/delpack` confirm button; +`DeleteStickerSet` succeeds; SIGTERM lands (or the 10s `handlerTimeout` expires — the handler has +already made 2-4 API calls by then). +- In `dropPackRecordIfSet`, `getPack` fails → returns early → the record survives naming a set that + no longer exists. Self-heals on the next command via `STICKERSET_INVALID`, so this half is benign. +- In `dropPackRecord` (reached from any `isStickerSetMissing` path), `getPack` fails, the pack record + is deleted anyway on the detached context, and `pack.Slug` is never known, so the reservation is + never released. Result: a `slug:` document with no pack and no set behind it, held against every + other user **forever** — nothing in the module can free it (`releaseSlug` needs both owner and + slug, and the only record of the slug was just deleted). Manual DB surgery is the only recovery. +- `handleNewPack:111-113` has the same shape: a deadline-exceeded bail calls `releaseSlug` with the + dead context, so the "release only what this invocation created" repair silently no-ops. + +Fix direction: derive the commit context once at the top of `releaseSlug` / `dropPackRecord` / +`dropPackRecordIfSet` and use it for the read as well as the write. These reads are part of the +commit, not part of serving the request. + +## MEDIUM — uncancellable image work stalls every user of the bot + +`internal/modules/sticker/image.go:35` (`maxDecodeDimension = 4096`), `:63-79` (the fallback ladder, +which re-scales from the **full-size** source `img` on every rung), reached from `/addsticker`, +`/newpack` and `/setpackicon`. + +`toStickerPNG` takes no context and checks none, so `handlerTimeout` bounds nothing here, and +handlers are strictly serialized (one worker), so this is a whole-bot stall, not a per-user one. +Measured on this box (ARM64, Go 1.27) with a 4096×4096 PNG of random 8×8 blocks — 1,278,612 bytes, +comfortably under the 2 MiB `maxSourceBytes` cap, and its 512px downscale is per-pixel noise, so +every PNG encode overshoots `softMaxStickerBytes` and the full ladder runs: + +``` +decode 175 ms +512 DefaultCompression 553 ms → 787,362 B (> 512 KiB, ladder continues) +512 BestCompression 41 ms → 773,200 B +448 BestCompression 522 ms → 568,571 B +384 BestCompression 513 ms → 411,425 B +320 BestCompression 475 ms → 280,071 B +TOTAL 2.28 s of uninterruptible CPU per message +``` + +One user resending that image faster than every 2.3s keeps the single dispatch goroutine saturated; +all other users' commands queue behind it. Peak live memory is also ~64 MB for the decoded source +alone (as the comment at `image.go:29-34` acknowledges). + +Fix direction: scale the ladder rungs from the already-downscaled 512 image instead of `img` (drops +three of the four expensive 4096²→N CatmullRom passes); lower `maxDecodeDimension` to ~1536-2048 +(the target is 512px, so nothing above that adds quality); optionally take `ctx` and bail between +rungs. + +## LOW — `/newpack` pays for the image before the check that refuses the caller + +`internal/modules/sticker/pack_handlers.go:68` (`resolveSource`) runs before the lock, before the +"you already have a pack" pre-check at `:92`, and before `reserveSlug`. A user who already owns a +pack can make the bot download up to 2 MB, run the full conversion above, and call +`UploadStickerFile` on every `/newpack`, only to be refused by a single store read that could have +run first. Not a new primitive (the same work is legitimately available via `/addsticker`), and the +ordering is what keeps name-burning closed, so this is cost, not a hole. Moving the cheap +`getPack` pre-check above `resolveSource` preserves the reserve-after-precheck invariant and removes +the free work. + +## LOW — `handleRenamePack` commits a record it read before taking the lock + +`internal/modules/sticker/pack_handlers.go:531-551`: `getPack` runs at `:531`, the lock is taken at +`:540`, and `commitPack` at `:550` writes the whole document (`Count`, `Pending`, `Name`, …) from +that pre-lock read. `adjustCount:388-395` documents exactly why that is wrong and re-reads inside +the lock; rename does not. `handleDelPack:32-83` likewise reads and writes the pending record with +no lock at all. Neither is exploitable today — `WithNotAsyncHandlers` + one worker means no two +handlers ever interleave — so the locks are currently decorative and these are latent regressions +that surface the day async handlers or a second replica are introduced. + +--- + +## Attacked and held + +Callback path (`delpack_callback.go`), payload fully attacker-chosen: +- **Address someone else's confirmation** — held: the store lookup key is + `pendingDeleteKey(query.From.ID)` (`:108`); the payload id is never used to select *whose* action + loads, only compared for equality at `:144`. +- **Press a bystander's button in a group** — held: `msg.Chat.ID != action.ChatID || msg.ID != + action.MessageID` (`:137`) is evaluated *before* any side effect, so the bystander cannot even + strip the victim's keyboard; `clearButton` only runs after that binding passes. +- **Replay / double-press / two live confirmations** — held: deterministic per-user key + (`pending_delete.go:64`) so a second `/delpack` supersedes the first, and the action is consumed + with `pending.Delete` *before* `DeleteStickerSet` (`:159-167`). +- **Stale press after `/delpack` + `/newpack`** — held: `dropPackRecordIfSet` (`pack_handlers.go:431`) + re-checks `ownsSet` so a stale confirmation cannot erase the record of the *new* live pack. +- **Forwarded copy of the prompt / inaccessible message** — held: `query.Message.Message == nil` + guard at `:125` before any use, plus the message-id binding. +- **Malformed payload** — held: `parseDeleteCallback` bounds length to 64, requires the prefix, and + requires lowercase hex; ids are 12 random bytes from `crypto/rand`. +- **Anonymous / bot senders** — held for the command side: `senderID` rejects `IsBot` and + `SenderChat != nil` (`sender.go:28-38`), so the shared GroupAnonymousBot identity can never own or + delete a pack; `query.From.ID == 0` is rejected on the callback side. + +Reservation lifecycle — every `slug:` create/delete site enumerated: +create at `reserveSlug:139` only; delete at `handleNewPack:112` (only when `created`), +`resolveStaleIntent:300` (only after a positive `STICKERSET_INVALID` on the old name), +`createOrAdopt:353` (only when `createRefused`), `dropPackRecord:479` (only after a confirmed +`DeleteStickerSet` or a positive `STICKERSET_INVALID`). All four funnel through `releaseSlug`, +which re-verifies the holder itself (`:187`) rather than trusting the caller, so a delete-by-name +cross-user primitive does not exist. Unknown/transient errors change nothing +(`createOrAdopt:325-331`, `resolveStaleIntent:303-307`) — verified by +`TestNewPack_UnknownLookupErrorAborts` and `TestNewPack_ResumedReservationSurvivesABail`. +The only leak I could construct is the cancelled-context one filed as MEDIUM above. + +- **Name burning at zero API cost** — held: the existing-pack pre-check precedes `reserveSlug` + (`:92-101`) and the `created` flag stops a bail from releasing a resumed reservation. Sustained + burning also needs one Telegram account per name, since one pack per user is enforced by the + create-only `PutVersioned(packKey, 0, …)` and `/delpack` returns the name to the pool. +- **Key-space collision across the three views over one collection** — held: + `slugRe = ^[a-z][a-z0-9_]{2,39}$` cannot emit `:` or a leading digit, so `"slug:"+slug` is + disjoint from decimal `packKey` and from `"pending-delete:"+decimal`; `storage.validateKey` + additionally rejects `/`, `.`/`..` and `__ns__`, and `provider.Collection("sticker")` isolates the + module (`registry.go:151`). Callback prefixes are conflict-checked bidirectionally + (`registry.go:219`). +- **Mutating another user's stickers via a replied sticker** — held: `resolveOwned` compares the + *stored* `Pack.Name` against `Sticker.SetName`, which is authored by Telegram and not settable by + the sender; a copied sticker lands in the copier's own set with a new file id, and the original + message still carries the victim's `set_name`. +- **Enumeration** — held: `slugTaken` (`pack_handlers.go:23`) is byte-identical to the + `PACK_SHORT_NAME_OCCUPIED` mapping in `apiRefusal` (`errors.go:62`), so "reserved in this bot" and + "occupied on Telegram" are indistinguishable; `notOwnedRefusal` is the single answer for no-pack, + pending-pack, and foreign-set. Raw Telegram descriptions never reach a reply — `replyAPIError` + maps or genericises. +- **Bot-token leakage** — held on every path I could reach: the download path discards the original + error entirely (`errDownloadFailed`, `download.go:36-83`, no `%w` of the transport error) and logs + only `classify(err)`, which inspects types and never formats the error; API-call failures have the + token redacted inside `url.Error.URL` by the library itself; user replies echo only `userError` + text (`state.go:95-101`). Replies are sent with no `ParseMode`, so a 64-char attacker-chosen title + echoed in `/mypack` and the delete prompt cannot inject markup either. +- **Error classification** — held: `isStickerSetMissing` requires both `bot.ErrorBadRequest` and the + `STICKERSET_INVALID` code; `createRefused` is a separate positive-only list of + request-validation codes and is used only to authorise undoing an intent + reservation. No path + infers absence from a generic failure. +- **Bounds ordering** — held except as noted in LOW: MIME allowlist and `FileSize` are checked before + any byte is fetched (`photo.go:64,74`), `GetFile.FileSize` is re-checked server-side, the body is + read through `LimitReader(max+1)` with an explicit overflow check, `DecodeConfig` bounds dimensions + before any pixel buffer exists, and the 20-emoji cap is enforced before the API call. + +--- + +## Recommended actions + +1. Gate adoption on `created == false` (reservation pre-dated this `/newpack`), and refuse to run + this module on a non-durable store. — HIGH +2. Use `commitContext` for the reads inside `releaseSlug` / `dropPackRecord` / + `dropPackRecordIfSet`. — MEDIUM +3. Scale the fallback ladder from the 512px image and lower `maxDecodeDimension`. — MEDIUM +4. Move the existing-pack pre-check above `resolveSource` in `/newpack`. — LOW +5. Re-read inside the lock in `handleRenamePack` (match `adjustCount`), or state in the module doc + that serialization is guaranteed by the dispatcher and the locks are belt-and-braces. — LOW + +## Unresolved + +- `go test ./internal/modules/sticker/...` failed once on its first (uncached) run — tail showed two + `sticker_newpack_lookup … upstream is unhappy` ERROR lines then `FAIL` — and then passed 25+ + consecutive runs including `-count=8` and `-race`, and under 8-way CPU load. Not reproduced, not a + security finding, but flagging it for whoever owns the tests: the suspects are + `TestNewPack_UnknownLookupErrorAborts` / `TestNewPack_ResumedReservationSurvivesABail`. +- Whether Telegram permanently reserves the short name of a deleted set (plan R11) is still + unverified. The code handles both answers correctly, so this is an open fact, not a defect. diff --git a/plans/reports/test-review-260825-1515-sticker-module.md b/plans/reports/test-review-260825-1515-sticker-module.md new file mode 100644 index 0000000..e091d77 --- /dev/null +++ b/plans/reports/test-review-260825-1515-sticker-module.md @@ -0,0 +1,411 @@ +# Test / harness / wiring review — sticker module + +Reviewer lens: test quality, harness correctness, integration & wiring. +Method: read all sources, then **mutation-tested 22 production mutations** against the +suite. Security exploitation and deep state-machine correctness owned by other reviewers. + +Verdict: test quality is **high** — 19 of 22 mutations killed, and the ownership gates, +callback binding, self-heal, panic barrier and count bookkeeping are all genuinely pinned. +Two rounds previously claimed "all new tests non-vacuous"; that is **refuted in three +places**: the `created`/reservation-release machinery, two `/addsticker` emoji tests, and +the module-registration wiring. None is a bug in shipped behaviour today; all are real +holes that would let a future regression land green. + +--- + +## 1. Mutation-test results + +Every mutation applied to production source only, run, then reverted. Restoration verified +by md5 + `diff -r` (see §7). + +| # | Mutation applied | File | Guarding test(s) | Result | +|---|---|---|---|---| +| M1 | Delete the `ownsSet` gate from `resolveOwned` | resolve.go:116 | `TestResolveOwned_RefusalsAreIdentical` | **KILLED** | +| M2 | `!found \|\| pack.Pending` → `!found` | resolve.go:113 | `TestResolveOwned_PendingRefusesIdentically` | **KILLED** | +| M3 | Disable foreign-holder refusal in `reserveSlug` | pack_handlers.go:159 | `TestNewPack_CannotSeizeAnotherUsersPack`, `..._ForeignReservationRefusedBeforeAnyAPICall` | **KILLED** | +| M4 | Disable reservation-owner proof in `resolveStaleIntent` | pack_handlers.go:263 | `TestNewPack_StaleIntentCannotAdoptForeignName` | **KILLED** | +| **M5** | **`if created { releaseSlug }` → never release** | **pack_handlers.go:111** | *(none)* | **SURVIVED** | +| **M6** | **Delete the pre-reservation quota check entirely** | **pack_handlers.go:92-99** | *(none)* | **SURVIVED** | +| **M7** | **`reserveSlug` resumed path returns `created=true`** | **pack_handlers.go:165** | *(none)* | **SURVIVED** | +| M8 | `dropPackRecordIfSet` → `dropPackRecord` (blind by-owner delete) | delpack_callback.go:178 | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` | **KILLED** | +| M9 | Disable chat/message binding check | delpack_callback.go:137 | `..._RejectsWrongBinding` (2 subtests), `..._BystanderCannotTouchAnotherUsersPrompt` | **KILLED** | +| M10 | Remove consume-before-destructive-call | delpack_callback.go:159 | `TestDelPackCallback_SecondPressIsInert` | **KILLED** | +| M11 | Disable expiry check | delpack_callback.go:149 | `TestDelPackCallback_RejectsExpired` | **KILLED** | +| M12b | `dropPackRecord` never releases the slug | pack_handlers.go:478 | `TestSelfHeal_ReleasesTheName`, `TestDelPackCallback_ReleasesTheName` | **KILLED** | +| M13 | `isStickerSetMissing` → `err != nil` (transient read as "gone") | errors.go:163 | 4 tests incl. both `_TransientErrorKeepsRecord` | **KILLED** | +| M14 | Remove the negative-count floor | pack_handlers.go:407 | `TestDelSticker_CountFlooredAtZero` | **KILLED** | +| M15 | Invert emoji precedence (replied beats explicit) | sticker_handlers.go:283 | `TestAddSticker_HappyPath`, `..._EmojiPrecedence/explicit_wins` | **KILLED** | +| **M16** | **Early-return before `AddStickerToSet` (no API call at all)** | **sticker_handlers.go:292** | *(none — see F2)* | **SURVIVED** | +| M17 | Drop the inherit-from-replied-sticker fallback | sticker_handlers.go:283 | `..._EmojiPrecedence/inherits_from_replied_sticker` | **KILLED** | +| M18 | Remove the command panic barrier | dispatcher.go:75 | `TestInstall_CommandPanicIsContained` (binary would die) | **KILLED** | +| M19 | Remove the command-hook panic barrier | dispatcher.go:83 | `TestInstall_CommandHookPanicIsContained` | **KILLED** | +| M20 | Callback barrier `onPanic` → nil (stop answering the query) | dispatcher.go:104 | `TestInstall_CallbackPanicIsContainedAndAnswered` | **KILLED** | +| M21 | `claimSlug` `PutVersioned(…,0,…)` → `Put` | pack_handlers.go:216 | `TestNewPack_DifferentSlugAdoptsExistingSet` | **KILLED** | +| M22 | `reserveSlug` `PutVersioned(…,0,…)` → `Put` | pack_handlers.go:139 | `TestNewPack_CannotSeize…`, `..._ForeignReservation…` | **KILLED** | +| **W1** | **Delete `"mypack": ""` from `expectedParameters`** | **cmd/server/command_menu_test.go:76** | *(none — see F4)* | **SURVIVED** | +| **W2** | **Unregister `sticker` from `factories()` (import removed too)** | **cmd/server/main.go:95** | *(none — see F5)* | **SURVIVED** | +| W3 | Corrupt a non-empty expectation (`"newpack": "WRONG"`) — control | command_menu_test.go:75 | `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` | **KILLED** | + +Survivors M5/M6/M7 and W2 were each re-run against the **full** sticker suite / **full repo +suite** (`./...`), not just a `-run` subset. All still survived. + +--- + +## 2. Findings + +### F1 — HIGH — the `created` reservation-release machinery has zero test coverage + +`internal/modules/sticker/pack_handlers.go:106-115` and `:138-165`. + +Both directions of the `created` flag survive mutation: + +- **M5** (never release): survived the full suite. +- **M7** (always release, even a resumed reservation): survived the full suite. + +Both branches are reachable. I proved it with two temporary probe tests (since removed): + +- *Release-on-bail is real*: `/newpack newslug` while a pending record for `oldslug` + exists and the old set resolves → `reserveSlug` writes `newslug` (`created=true`), + `claimSlug`→`resolveStaleIntent` adopts `oldslug` and returns `done=true`, so + `releaseSlug(newslug)` must run. Under M5 the probe failed with + `newslug still reserved with no pack behind it - name burned`. Reservations are global + and permanent, so this is a per-invocation namespace leak. + **`TestNewPack_DifferentSlugAdoptsExistingSet` (pack_handlers_test.go:111) walks exactly + this path and asserts nothing about `newslug`.** One added line closes it. +- *Not-releasing-a-resumed-reservation is real*: reserve `oldslug` for the caller, pending + record under a different slug, `getStickerSet` fails unclassifiably → `resolveStaleIntent` + bails `done=true`. Under M7 that probe failed: the caller's pre-existing reservation was + destroyed, handing the name to the next asker while the set may still exist. + +**`TestNewPack_ResumedReservationSurvivesABail` (pack_handlers_test.go:573) does not test +what its name says.** In that test `claimSlug` returns `done=false` (same-slug resume), so +the `if created` branch is never reached; the bail happens later in `createOrAdopt`, which +never consults `created`. M7 survives it. It is a duplicate of +`TestNewPack_UnknownLookupErrorAborts` wearing a different name. + +Fix: assert `newslug` is unreserved in `TestNewPack_DifferentSlugAdoptsExistingSet`, and +re-point `TestNewPack_ResumedReservationSurvivesABail` at a bail inside +`claimSlug`/`resolveStaleIntent` (a pending record under a *different* slug + a 500 from +`getStickerSet` reaches it). + +### F2 — MEDIUM — two `/addsticker` emoji tests pass when no sticker is added at all + +`handlers_test.go:134-159` (`TestAddSticker_EmojiPrecedence`) and `:163-178` +(`TestAddSticker_FallsBackToDefaultEmoji`). + +Both use the pattern: + +```go +for _, call := range rb.Sent() { + if call.Method == "addStickerToSet" && !strings.Contains(call.Form["sticker"], tc.want) { + t.Errorf(...) + } +} +``` + +Zero matching calls ⇒ zero assertions ⇒ pass. **M16** confirms it: an early `return` placed +before `b.AddStickerToSet` leaves both tests green. They are half-live (M15/M17 kill +individual subtests via the emoji value) but they do not guard "a sticker was added". + +`TestAddSticker_HappyPath:108` has the right guard (`countMethod(...) != 1` + `Fatalf`). +Add the same two lines to both tests. Same latent shape at `resolve_test.go` — no, those +use explicit `countMethod` comparisons and are fine. + +### F3 — MEDIUM — `docs/sticker-packs.md` contradicts the reservation lifecycle it describes + +`docs/sticker-packs.md:99-101`: + +> "A name is claimed only when a pack is actually created, and it is released when that +> pack is deleted … A `/newpack` that is refused claims nothing." + +The first clause is false and inverts the module's central safety property. `reserveSlug` +writes the reservation **before Telegram is touched** — that write-ahead claim is precisely +what makes adoption safe, and `pack.go:236-239` plus `pack_handlers.go:120-137` say so at +length. A name is therefore held while a `/newpack` is merely *pending*, and +`TestNewPack_UnknownLookupErrorAborts:181` asserts the reservation **must survive** when no +pack was created. An interrupted `/newpack` holds its name indefinitely with no pack behind +it — the doc tells a reader the opposite. + +The second clause is also too strong: a *classified* refusal releases, but an unknown +`getStickerSet`/`createNewStickerSet` failure deliberately keeps both intent and +reservation (`pack_handlers.go:325-331, 347-356`). + +Everything else in the doc checks out against source: 512px long edge / 100×100 thumbnail +(`image.go:23,25`), 4096px cap (`maxDecodeDimension = 4096`), 2 MB (`maxSourceBytes`), +120 stickers, 1–20 emoji, 10-minute confirm TTL, 10-second handler deadline, slug rules, +`MODULES` semantics, uniform ownership refusals. + +### F4 — LOW — `command_menu_test` does not verify that a command has an expectation + +`cmd/server/command_menu_test.go:104`: `got != expectedParameters[command.Name]`. A missing +map key yields `""`, so any public command with empty `Parameters` that nobody added to the +map passes silently. **W1** confirms: deleting `"mypack": ""` changes nothing. Four of the +nine new entries (`mypack`, `delsticker`, `setpackicon`, `delpack`) are therefore +decorative. The test does not verify what its name ("AllPublicCommandsHaveSafeMetadata") +promises for parameterless commands. + +Fix: `want, ok := expectedParameters[command.Name]; if !ok { t.Errorf("no expectation for /%s") }`. +That also turns the test into the missing registration guard for F5. + +### F5 — MEDIUM — nothing pins that the sticker module is registered at all + +**W2**: with both the import and `"sticker": sticker.New` removed from `cmd/server/main.go`, +`go test ./...` is **fully green** — all 25 packages pass, including +`internal/modules/sticker` (its tests construct `state` directly and never go through +`factories()`). A bad merge or rebase that drops the factory line ships a bot with none of +the nine commands and a green CI. + +`command_menu_test.go` only iterates whatever `reg.PublicCommands()` returns, so an absent +module is invisible to it. Cheapest fix is F4's `ok` check, which makes the map an +inventory rather than a lookup. + +### F6 — LOW — `/newpack` documented but unregistered `MODULES` default changed silently + +`.env.example` flips `MODULES=` (empty ⇒ load everything) to an explicit 12-module list. +The list matches `factories()` exactly (verified key by key), so no module is dropped today. +But it is now a hand-maintained duplicate of `factories()` with no test tying the two +together — the next module added will be silently excluded for anyone starting from the +template. Worth a comment pointing at `factories()`, or a test. + +--- + +## 3. Harness review — `internal/testutil/recording_bot.go` + +All four questions checked empirically. **The harness changes are correct and +backwards-compatible.** + +- **`FailMethodCode` produces the real sentinels — confirmed.** The library switches on + `r.ErrorCode` decoded from the *body* and ignores the HTTP status + (`go-telegram/bot@v1.20.0/raw_request.go:102-131`). `FailMethodCode` marshals + `{"ok":false,"error_code":…,"description":…}`, so `errors.Is(err, bot.ErrorBadRequest)` + holds. `recording_bot_test.go:113` pins this, and `:130` pins the negative contrast for + bare `FailMethod`. The doc comment's `raw_request.go:103-125` citation is accurate. +- **`StubMethod` / `FailMethod` precedence is correct and tested.** `handle()` checks + `shouldFail` before `hasStub` (`:200-209`); `TestRecordingBot_FailureWinsOverStub` covers it. +- **`Reset()` is coherent and unchanged for existing callers.** It clears `calls` only — + which is exactly what it did before this changeset; the diff only adds a doc comment + explaining it. `nextMessageID` is deliberately not reset (IDs stay unique across a + Reset), which no caller depends on. All 13 other packages that use the harness call only + `Reset()`; **no module outside `sticker` uses `FailMethod`, `FailMethodCode` or + `StubMethod`**, so the new precedence rule cannot affect them. +- **Message IDs start at 1, not 0.** `handle()` increments *before* assigning + (`:192-195`), so the first `sendMessage` returns `message_id: 1`. This matters because + production rejects a binding with `action.MessageID == 0` (`delpack_callback.go:137`) — + I verified the full round trip (`/delpack` → press the button the handler itself + produced → `deleteStickerSet` fires exactly once). No collision. *(Observation only: + no test in the suite actually performs that round trip; the pieces are covered + separately.)* + +**One real harness concern (MEDIUM):** + +The multipart-parse tolerance (`:180`) is justified — I confirmed `getMe` genuinely fails +with `multipart: NextPart: EOF` and `ContentLength=-1`, so the old code made every +parameterless method untestable. But the tolerance is **wider than the justification**: I +posted a deliberately malformed multipart body (`Content-Type: multipart/form-data; +boundary=zzz` with non-multipart content) to `/sendMessage` and the harness answered +**HTTP 200** and recorded `{Method:sendMessage Form:map[]}`. A real Telegram would 400 it. + +Impact is bounded — the bot library always builds well-formed multipart, so production +cannot realistically emit garbage. The live risk is **masking**: any test asserting a form +field is *absent* would falsely pass if the whole form silently failed to parse. Such +assertions already exist outside this module, e.g. +`internal/modules/stock/dividend_flow_test.go:115,139` (`calls[1].Form["reply_markup"] != ""`). + +Suggested narrowing: tolerate only the empty-body case (`r.ContentLength <= 0`, or the +`NextPart: EOF` shape) and keep the 400 for genuinely malformed bodies; or record a +`ParseFailed bool` on `SentCall` so a masked parse is visible in `dumpCalls()`. + +--- + +## 4. Untested error branches + +Prioritised by blast radius if a bug landed there. `✗` = no test reaches the branch. + +**`pack_handlers.go` — state-corrupting or namespace-leaking:** + +- `:111` `if created { releaseSlug }` — ✗ **both directions** (F1). Name-burn / name-theft. +- `:178-196` `releaseSlug`'s own cross-user ownership guard (`held.OwnerID != ownerID`) — ✗. + Defence-in-depth against a bad call site, with zero coverage; a caller passing the wrong + owner would be caught only here. +- `:325-331` `createOrAdopt` default branch is covered, but **`:347-356` create failing with + an *unclassifiable* error** (intent + reservation must both survive) — ✗. This is the exact + mirror of `TestNewPack_UnknownLookupErrorAborts` and is the higher-risk half, since a + wrong answer here strands a slug whose set may exist. +- `:396-413` `adjustCount`'s `!found` → `storage.ErrNotFound` path, and both handler + fallbacks that consume it (`sticker_handlers.go:308-314`, `:352-359`) — ✗. These + synthesise a count for the reply; a bug shows the user a wrong number. +- `:147-158` `reserveSlug` conflict-but-unreadable (`getErr != nil || !found` → treat as + taken) — ✗. Comment calls out that guessing the other way *is* the takeover. +- `:220-229` `claimSlug` non-conflict store error / re-read failure — ✗. +- `:258-272` `resolveStaleIntent` reservation-read error, and both `Put` failures + (`:267`, `:296`) — ✗. +- `:283-286` adopt-commit failure — ✗. +- `:73-77` `resolver.resolve` (GetMe) failure, `:78-81` `makeSetName` failure through the + handler — ✗ (`makeSetName` is unit-tested, the handler branch is not). +- `:92-94` pre-check store error, `:495-499` `/mypack` store error, `:531-535` /`renamepack` + store error, `:542-547` `/renamepack`'s `isStickerSetMissing` self-heal — ✗. +- `:366-369`, `:550-554` commit failures — ✗ (both are best-effort by design). + +**`delpack_callback.go`:** + +- `:144-147` `action.ID != id` → clear button + "replaced by a newer /delpack" — ✗. + `TestDelPack_SecondPromptSupersedesTheFirst` is rejected earlier, at the *binding* check + (`:137`), so this branch and its `clearButton` side effect never execute in tests. +- `:97-100` malformed callback data through the handler — ✗ (`parseDeleteCallback` is + unit-tested at `delpack_callback_test.go:84` for the happy case only; no test feeds + over-length, non-hex or wrong-prefix data to `handleDelPackCallback`). +- `:105-107` `query.From.ID == 0`, `:118-120` `From.ID != action.OwnerID` — ✗ (both + unreachable given the owner-keyed lookup; defence in depth). +- `:113-116` pending-store read error, `:159-165` both consume-delete failure branches — ✗. +- `:32-36` `/delpack` store error and `:37-39` `!found` ("you don't have a pack") — ✗. +- `:70-73` `SendMessage` failure — ✗. Note this is the one path in the module that returns + a **raw** API error to the dispatcher rather than a `userError`/generic reply. +- `:80-83` pending `Put` failure — ✗. Leaves a live button with no server-side action. + +**`resolve.go`:** + +Well covered. Only `:108-111` `getPack` store error is ✗. `resolveSource`'s `replied == nil` +refusal (`:51-53`) is ✗ directly, though `resolveOwned`'s equivalent is tested. + +**Whole-feature gap — Phase 5 photo pipeline has no integrated coverage.** Every test +message is built by `stickerReply()`, which always sets `Sticker`. Grep confirms no test +constructs a `Photo:` / `Document:` reply and feeds it to a handler. Consequently +`resolvePhotoSource` (`photo.go:342`) is never executed, and **`handleSetPackIcon` +(`setpackicon.go:174`) is reached only by `TestHandlers_RefuseAnonymousSenders`, which +returns at the sender check before doing anything** — its download → resize → +`SetStickerSetThumbnail` → self-heal body is entirely untested. The pieces (`photoFileID`, +`downloadFile`, `toStickerPNG`, `toThumbnailPNG`) are individually well tested; the wiring +between them is not. `StubMethod("uploadStickerFile", …)` now makes this testable — that is +what the harness change was for. + +--- + +## 5. Test isolation, races, lint + +- `go test -race ./internal/modules/... ./internal/testutil/... ./cmd/server/...` — **clean, + exit 0, 0 `DATA RACE`**, all 17 packages ok. +- `golangci-lint run` on the four changed packages — **0 issues**. +- No shared mutable fixtures: every test builds its own `newTestState()` over a fresh + `storage.NewMemoryProvider()` and its own `RecordingBot`. No ordering dependence found. +- `syncBuffer` (dispatcher_panic_test.go:33) correctly mutex-guards the log sink for the + detached-goroutine test, and `waitForLog` polls rather than sleeping. Good. +- Two globals are mutated without isolation in `dispatcher_panic_test.go`: `log.SetDefault` + (restored via defer) and `metrics` counters (`metrics.Flush()` at :116, never reset). + Harmless today — nothing runs in parallel and no other test in `modules_test` asserts on + error counters — but the metrics assertion at :130 would become order-dependent if one + ever did. Worth a note, not a change. +- `seedPack` correctly seeds the slug reservation alongside the pack record + (handlers_test.go:45-50), and `seedInterrupted` does the same + (pack_handlers_test.go:387-398). Both carry a comment explaining that seeding the record + alone builds a state production cannot reach. This is the right instinct and it is why + M12b/M22 kill cleanly. + +## 6. Wiring & plan accuracy + +**Registered correctly:** all 9 commands appear in `sticker.go:22-82` with +`VisibilityPublic`, descriptions, and `Parameters` matching +`docs/command-parameter-conventions.md` (the new `<name...>` "required remaining text" row +is a genuine addition, used by `<title...>`). All 9 are menu-described and +parameter-documented. README table and `docs/sticker-packs.md` list the same 9. + +**Callback prefix is unique.** `callbackPrefix = "sticker_pack:"` vs the only other +callback in the repo, `stock`'s `"stock_div:"`. `registry.go:218-220` enforces this +**bidirectionally** (`HasPrefix` both ways), so the check is real, not nominal. + +**Plan accuracy** — `plan.md` status is honestly `partial`, and all 16 unchecked boxes are +live-Telegram smoke tests plus the unresolved R11 (does Telegram reserve deleted short +names). No inflated completion. Two checked boxes are contradicted by shipped code: + +- `phase-03:245` — "`/newpack` where `GetStickerSet` fails with a non-missing error aborts, + **deletes the pending record**, and never calls `CreateNewStickerSet`" is `[x]`, but the + shipped code deliberately **keeps** both intent and reservation + (`pack_handlers.go:325-331`), and `TestNewPack_UnknownLookupErrorAborts:174-183` asserts + that. The file's own superseding note at `phase-03:52-55` says this step is wrong — the + checkbox was ticked against the superseded text. +- `phase-03:225` — "`sticker.go` factory registering **4 commands** + callback prefix" is + `[x]`; the shipped factory registers 9. Stale phase-scoped wording, harmless. + +**`internal/modules/wordle/lookup_test.go`** — confirmed a pure `gofmt` alignment change. +The 8 map keys and all 8 values are byte-identical; only leading whitespace inside the +composite literal moved (the longest key `" crane "` no longer forces extra padding). +No behaviour change. + +## 7. Final state + +Every mutated file restored and verified byte-identical against a pre-review backup: + +``` +md5sum -c backup/md5.txt → all 31 files OK (no mismatches) +diff -r backup/cmdserver cmd/server → cmd/server IDENTICAL +diff -r backup/testutil internal/testutil → testutil IDENTICAL +diff -r backup/sticker internal/modules/sticker → sticker IDENTICAL +diff backup/dispatcher.go internal/modules/dispatcher.go → dispatcher IDENTICAL +``` + +Three temporary probe test files were created and removed (`zz_probe_test.go`, +`zz_probe2_test.go` in `sticker`; `zz_probe_test.go` in `testutil`); none remain. + +`git status --short`: + +``` + M .env.example + M README.md + M cmd/server/command_menu_test.go + M cmd/server/main.go + M docs/command-parameter-conventions.md + M go.mod + M go.sum + M internal/modules/dispatcher.go + M internal/modules/wordle/lookup_test.go + M internal/testutil/recording_bot.go + M internal/testutil/recording_bot_test.go + M plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md + M plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md + M plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md + M plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md + M plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md + M plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md + M plans/260824-1051-sticker-pack-module/plan.md +?? docs/sticker-packs.md +?? internal/modules/dispatcher_panic_test.go +?? internal/modules/sticker/ +?? plans/reports/correctness-review-260825-1515-sticker-module.md +?? plans/reports/security-review-260825-1515-sticker-module.md +``` + +Identical to the state at review start, plus the two peer reviewers' reports and this file. + +`go test ./...` — **all 25 packages ok**, zero failures. +`go test -race` on all module + testutil + cmd/server packages — **ok, 0 data races**. +`golangci-lint run` on changed packages — **0 issues**. + +## 8. Recommended actions + +1. **(F1, high)** Assert `newslug` is released in `TestNewPack_DifferentSlugAdoptsExistingSet`; + re-point `TestNewPack_ResumedReservationSurvivesABail` at a bail inside + `claimSlug`/`resolveStaleIntent` so it kills M7. Two tests, ~6 lines. +2. **(F5 + F4, medium)** Add the `want, ok := expectedParameters[name]` presence check in + `command_menu_test.go`. Closes both the decorative-entry hole and the missing + registration guard in one edit. +3. **(F2, medium)** Add the `countMethod(rb, "addStickerToSet") != 1` + `Fatalf` guard to + `TestAddSticker_EmojiPrecedence` and `TestAddSticker_FallsBackToDefaultEmoji`. +4. **(F3, medium)** Correct `docs/sticker-packs.md:99-101` to describe the write-ahead + reservation: the name is claimed *before* Telegram is called and is held while an + attempt is pending; only a positively-classified refusal or a confirmed delete releases it. +5. **(§3, medium)** Narrow the multipart tolerance to the empty-body case, or surface a + `ParseFailed` flag on `SentCall`. +6. **(§4, medium)** Add one integrated photo test (`StubMethod("uploadStickerFile", …)`) + and one `handleSetPackIcon` happy path — the largest untested surface in the module. +7. **(§4, low)** Cover `createOrAdopt`'s unclassifiable-create-error branch and + `delpack_callback.go:144` (`action.ID != id`). +8. **(§6, low)** Untick or correct `phase-03:245`; fix the "4 commands" wording at `:225`. + +## 9. Unresolved questions + +- `phase-06` leaves R11 (does Telegram permanently reserve a deleted short name?) open, and + `dropPackRecord`'s comment reasons about it both ways. Nothing here can settle it without + a live token; the code's behaviour is safe under either answer, so it is correctly + deferred to the manual smoke list. +- Is the `.env.example` switch from empty-`MODULES` to an explicit list intended to change + deployed behaviour, or only to document intent? If deployments copy the template, adding + a future module will require an `.env` edit that nothing warns about. diff --git a/plans/reports/verify-260825-1630-sticker-round5.md b/plans/reports/verify-260825-1630-sticker-round5.md new file mode 100644 index 0000000..e1b7131 --- /dev/null +++ b/plans/reports/verify-260825-1630-sticker-round5.md @@ -0,0 +1,260 @@ +# Sticker module — round 5 adversarial verification + +Commit `e81b1b7` ("fix(sticker): never adopt an existing pack"), branch +`feature/sticker-pack-module`, Go 1.27, golangci-lint v2.13.1. + +**Verdict: DO_NOT_MERGE.** The adoption class is genuinely closed — every route +to a committed `Pack` record naming a foreign set was enumerated and executed, +and all of them refuse. But the round-5 pattern repeated in the *other* +direction: the commit correctly identified that `DeleteStickerSet` is a +cross-user primitive keyed by set name, added a guard for one way of reaching +it, and left a second way open. A stale `/delpack` confirmation destroys +whichever user holds that name at press time. + +--- + +## C1 (Critical) — a stale `/delpack` confirmation deletes a re-issued name's pack + +`internal/modules/sticker/delpack_callback.go:177-185` + +```go +if current, found, err := getPack(ctx, s.store, action.OwnerID); err != nil { + ... +} else if found && current.Pending && ownsSet(current, action.SetName) { + // refuse +} +// falls through to DeleteStickerSet(action.SetName) +``` + +The re-check is **negative** — it blocks exactly one bad state (`Pending`) — where +it needed to be **positive**: only proceed when the record still authorises this +delete. `!found` and "record now names a different set" both fall through to the +destructive call. `dropPackRecordIfSet` performs precisely the right check +(`found && ownsSet`), but it runs *after* `DeleteStickerSet`, so it protects the +local record and not the set. + +### Reproduction (executed; all steps are ordinary public commands) + +Test `TestProbe_StaleConfirmationDeletesReissuedName`, run against +`internal/modules/sticker`: + +| # | Actor | Command | Effect | +|---|-------|---------|--------| +| 1 | U | `/newpack mypack Mine` | committed record + reservation `mypack` | +| 2 | U | `/delpack`, **do not press** | `PendingDelete{SetName: mypack_by_testbot}` stored, TTL 10 min | +| 3 | U | `/delsticker` down to zero, then any command | set gone at Telegram → `STICKERSET_INVALID` → `dropPackRecord` drops the record **and releases the reservation**. The unpressed confirmation is untouched — no `dropPackRecord` path clears `s.pending`. | +| 4 | V | `/newpack mypack Victim Pack` | reservation free, `GetStickerSet` missing → V legitimately creates and owns `mypack_by_testbot` | +| 5 | U | presses the button from step 2 | binding OK, not expired, re-check sees `found=false` → **`deleteStickerSet name=mypack_by_testbot`** | + +Observed output: + +``` +step3 self-heal: U record found=false, reservation held=false +step3 U's unpressed confirmation SURVIVED the self-heal +step5 V pack found=true {Slug:mypack Name:mypack_by_testbot ... OwnerID:4242 Pending:false} +step6 methods = [deleteStickerSet editMessageReplyMarkup sendMessage answerCallbackQuery] +HOLE CONFIRMED: U deleted "mypack_by_testbot" — V's pack, created after U's record was gone +step6 V record still found=true (local record survives, set does not) +step6 answer = "Pack deleted." +``` + +V is left with a committed record pointing at a destroyed set, and U is told the +delete succeeded. + +**Reachability: production, not hypothetical.** Dispatch is serial +(`internal/telegram/client.go:28` `WithNotAsyncHandlers`, single worker) — this is +a plain sequential command sequence, no race. Steps 1-3 are fully under the +attacker's control and take seconds; the only constraint is that step 4 lands +inside the 10-minute `pendingDeleteTTL`. The same sequence also occurs with no +attacker at all: run `/delpack`, get distracted, empty the pack, run one more +command, and whoever takes the freed name loses it when you finally press. + +Second, milder variant, also executed +(`TestProbe_StaleConfirmationAfterRecordMovedOn`): with the record moved to a +different pack, the press still issues +`deleteStickerSet name="mypack_by_testbot"` while the record names +`other_by_testbot`. `dropPackRecordIfSet` correctly leaves the record alone — +after the set is already gone. + +### Fix shape + +Invert the guard to a positive authorisation, matching `dropPackRecordIfSet`: + +```go +current, found, err := getPack(ctx, s.store, action.OwnerID) +if err != nil { ...transient answer... } +if !found || current.Pending || !ownsSet(current, action.SetName) { + s.dropPendingDelete(ctx, key) + clearButton(ctx, b, action.ChatID, action.MessageID) + return answerCallback(ctx, b, query.ID, "That pack is no longer yours to delete; nothing was deleted at Telegram.") +} +``` + +Additionally, every `dropPackRecord` / `dropPackRecordIfSet` should clear +`pendingDeleteKey(ownerID)`. A record that no longer exists must not leave a live +capability behind it; the TTL is the only thing bounding it today. + +--- + +## H1 (High) — the round-5 re-check is not pinned by any test + +Mutation M2b: delete the entire `getPack` re-check block from +`handleDelPackCallback` (delpack_callback.go:177-185). + +**SURVIVED.** Full suite `ok`. Item 4 of the commit description — "a defensive +re-check was also added in `handleDelPackCallback` under the lock" — has zero +test coverage. This is the same defect class the memory file records: a claim +asserted in the commit text that no test exercises. It is also the exact guard +whose incompleteness produces C1, so the gap and the bug are the same omission. + +--- + +## Mutation results (full) + +Backed up to scratchpad, restored, `md5sum -c` all match, `git status --short` +empty (verified after every mutation). + +| # | Mutation | Result | Killing test | +|---|----------|--------|--------------| +| M1 | `createPack` `err == nil` → `finishNewPack` (adoption restored) | **killed** | `TestNewPack_InterruptedAttemptWithLiveSetIsRefused`, `TestNewPack_WipedStoreCannotAdoptSurvivingPack`, `TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver` | +| M2 | remove `/delpack` pending short-circuit | **killed** | `TestDelPack_PendingRecordDeletesNothingAtTelegram` | +| **M2b** | **remove `handleDelPackCallback`'s under-lock re-check** | **SURVIVED** | — | +| M3 | neutralise `releaseSlug` ownership check | **killed** | `TestReleaseSlug_RefusesANameHeldBySomeoneElse` | +| M4 | re-attach `releaseSlug`'s ownership read to the request ctx | **killed** | `TestReleaseSlug_CompletesOnACancelledContext` | +| M5b | `resolveStaleIntent` `err == nil` branch adopts the old set (R4's second path) | **killed** | `TestNewPack_DifferentSlugDoesNotAdoptExistingSet` | +| M6 | release the slug unconditionally on claim bail (drop the `created` guard) | **killed** | `TestNewPack_ResumedReservationNotReleasedWhenClaimBails` | +| **M7** | **`resolveStaleIntent` missing-branch no longer releases the dead name** | **SURVIVED** | — | +| M8 | `createPack` unknown-lookup branch drops intent + reservation | **killed** | `TestNewPack_UnknownLookupErrorAborts` | +| M9 | `createPack` refusal keeps the intent | **killed** | `TestNewPack_InterruptedAttemptWithLiveSetIsRefused` | + +M7 detail: `TestNewPack_DifferentSlugReplacesDeadIntent` (pack_handlers_test.go:149) +is named for replacing a dead intent but asserts only the *new* pack. Nothing +checks that `oldslug`'s reservation was freed, so the R1 name-burn class is +unpinned. The code is correct today; only the regression barrier is missing. + +The four tests added by this commit are otherwise non-vacuous: M1 kills +`TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver`, M2 kills +`TestDelPack_PendingRecordDeletesNothingAtTelegram`, M3/M4 kill the two +`TestReleaseSlug_*` tests. The `ctxHonouringSlugs` wrapper is load-bearing — its +comment ("this assertion passed whether or not the code detached until the store +was made to honour cancellation") is accurate. + +--- + +## What the commit did close (verified by execution, not inspection) + +### No path to a committed record naming a foreign set + +`Pending = false` is written in exactly one place, `finishNewPack` +(pack_handlers.go:352), reachable only after `CreateNewStickerSet` returns nil. +`adjustCount` and `handleRenamePack` preserve/require the flag. Enumerated and +executed in `TestProbe_PostWipeAttackSurface`, `TestProbe_PendingToCommittedSweep`, +`TestProbe_CommittedRecordOnlyAfterCreate`: + +- post-wipe `/newpack victimslug` with the set live → `slugTaken`, record dropped, + reservation released, no `createNewStickerSet` +- inconclusive probe, then a second `/newpack` with the set live → refused, cleaned +- resume with a pending record naming the victim's set → refused, cleaned +- `createNewStickerSet` refused (`PACK_SHORT_NAME_OCCUPIED`) → intent and + reservation both released +- with a pending record naming the victim's set, all six other commands refuse: + `/addsticker` and `/renamepack` → `noPackYet`; `/delsticker`, `/editsticker`, + `/ordersticker`, `/setpackicon` → `notOwnedRefusal`. **Zero** Telegram + mutations in every case. + +### `/delpack` pending short-circuit cannot be aimed at another user + +`getPack(ctx, s.store, senderID(msg))` and `dropPackRecord(ctx, ownerID)` are +owner-keyed throughout, and `releaseSlug` re-verifies the holder itself +(M3 confirms). Executed: a caller can only free a reservation they hold +(`sticker_release_slug_refused` logged otherwise). Freeing a name that still has +a live set behind it is possible but not exploitable — the next claimant is +refused by `createPack`'s occupancy probe (verified: third party gets +`slugTaken`). + +### No wedge + +`TestProbe_WedgeAudit`, 4 leftover states × 3 escape routes = 12 runs. Every +state escapes via `/newpack <other-slug>`; 11 of 12 also escape via the same +slug or `/delpack`. The one refusal (`pending record + foreign reservation`, +same slug) is correct and has two working escapes. + +--- + +## M (Medium) — resuming an interrupted `/newpack` silently uses the old title + +`claimSlug`'s resume branch (pack_handlers.go:246) returns `existing`, discarding +the freshly parsed `title`. Executed (`TestProbe_ResumeIgnoresNewTitle`): after a +pending `mypack`/"Old" record, `/newpack mypack Brand New Title` calls +`createNewStickerSet title="Old"` and answers `"Created Old."` — confirming a +title the user did not type, with `/renamepack` the only fix. The same branch +also reuses `existing.Name`, so after a BotFather rename every resume builds a +set name with the stale `_by_<old>` suffix that Telegram will reject; the +freshly computed `setName` is discarded. + +## L1 — `resolveStaleIntent` leaks the old reservation permanently + +The `err == nil` branch keeps the old reservation with no record pointing at it +(`TestProbe_StaleIntentReservationLeak`: `reservation[oldslug] owner=42`, no pack +record). Correct in intent — a set really occupies the name — but the entry is +unreachable by any code path while the user holds another committed pack. Not +weaponisable: reaching the branch requires a set to genuinely exist under the +name, so it is not a cheap name-burn primitive. + +## L2 — confirmed delete leaks the reservation when the record moved on + +`TestProbe_ConfirmedDeleteLeaksReservationWhenRecordMoved`: `DeleteStickerSet` +succeeds, `dropPackRecordIfSet` correctly declines to touch the moved record, and +nobody releases `mypack` — a name whose set is now definitely gone stays reserved +forever. Same code path as C1's second variant. + +## L3 — `releaseSlug` read-then-delete is not atomic + +`getSlugReservation` then `Delete` on the same key with no CAS. Under concurrent +dispatch, a reservation re-claimed between the two calls would be deleted by the +previous holder. **Not reachable today** (serial dispatch), but `state.go:83` +documents the lock as "load-bearing rather than decorative" because of the cron +scheduler and stats hook — worth a `PutVersioned`-style compare-and-delete or an +explicit note that neither touches this store. + +--- + +## Gates + +| Gate | Result | +|------|--------| +| `go build ./...` | pass | +| `go vet ./...` | pass | +| `gofmt -l .` | clean | +| `golangci-lint run ./...` | 0 issues | +| `go test ./...` | pass | +| `go test -race ./...` | pass | +| `go test -race -count=20 ./internal/modules/sticker/...` | pass, 86.5s, no flakes | +| workspace restored | `md5sum -c` all match, `git status --short` empty | + +## Recommended actions + +1. **Blocking** — fix C1: invert the `handleDelPackCallback` re-check to positive + authorisation (`!found || Pending || !ownsSet` → refuse). +2. **Blocking** — clear `pendingDeleteKey(ownerID)` in `dropPackRecord` and + `dropPackRecordIfSet`, so a dropped record cannot leave a live delete + capability behind. +3. **Blocking** — add a test for the re-check (kill M2b) covering all three bad + states: `!found`, `Pending`, and record-names-another-set. The end-to-end + sequence in C1 is the right shape. +4. High — extend `TestNewPack_DifferentSlugReplacesDeadIntent` to assert the old + reservation was released (kill M7). +5. Medium — carry the new title (and freshly computed set name) through the + resume branch, or state in the reply that the original title was kept. +6. Low — release the reservation in L2's branch; document or close L3. + +## Unresolved questions + +- Does Telegram in fact delete a sticker set when its last sticker is removed? + The module documents this as undocumented behaviour. C1's step 3 uses it as the + cheapest self-service way to make the set vanish, but the hole does not depend + on it — any external deletion, or any `STICKERSET_INVALID` self-heal, reaches + the same state. +- Is `pendingDeleteTTL` (10 min) intended as a security bound? It is currently + the only thing limiting C1's exploitation window, and the comment justifies it + on irreversibility grounds rather than as an authorisation control. diff --git a/plans/reports/verify-260825-1700-sticker-round6.md b/plans/reports/verify-260825-1700-sticker-round6.md new file mode 100644 index 0000000..9f262f7 --- /dev/null +++ b/plans/reports/verify-260825-1700-sticker-round6.md @@ -0,0 +1,310 @@ +# Adversarial verification — sticker module, round 6 + +- Commit under review: `b7803ce` ("fix(sticker): prove authority before a confirmed pack delete") +- Branch: `feature/sticker-pack-module`, Go 1.27, golangci-lint v2.13.1 +- Method: static enumeration + driven end-to-end probes + mutation testing. + All source mutations were backed up, restored, and verified byte-identical + (`git status --short` empty, md5sums match baseline). + +## Verdict + +**The security fix is correct.** I could not reach any of the seven +owner-unscoped Telegram mutations with authority the caller does not hold, +under serial dispatch or under forced concurrency. The R5 hole +(`DeleteStickerSet` via a stale confirmation) is closed twice over, and I +confirmed by driven probe that the allowlist alone still holds in the one +production state where change 2 fails. + +**The recurring pattern did repeat, one level down.** The flagship new test is +vacuous with respect to the guard it is named for, and the two disjuncts of the +new allowlist that the commit message itself identifies as the R5 bug are +completely unpinned. Nothing in the suite stops this fix from regressing back +into exactly the blocklist it replaced. + +That is a test-integrity defect, not a live exploit. See "Merge position". + +--- + +## 1. Enumeration of every owner-unscoped Telegram mutation + +`grep` over `internal/modules/sticker/*.go` (non-test) yields exactly these +mutating calls. For each: what proves ownership at the moment of the call, and +whether that proof can go stale or be manufactured. + +| Call | Site | Proof of authority at call time | Can it go stale / be forged? | +|---|---|---|---| +| `DeleteStickerSet` | `delpack_callback.go:210` | Under `lockUser`, immediately before the call: `found && !current.Pending && ownsSet(current, action.SetName)` re-read from the store | **No.** Non-`Pending` records are written only by `finishNewPack` (after a successful `CreateNewStickerSet`) and by `commitPack` from `adjustCount`/`handleRenamePack`, both of which copy an existing record's `Name`. So a non-`Pending` record proves this owner created that set. Gap between check and call is one store `Delete` on the pending key, no dispatch point. | +| `CreateNewStickerSet` | `pack_handlers.go:370` | `GetStickerSet(pack.Name)` must positively return `STICKERSET_INVALID` in the same handler | No adoption path remains; `err == nil` (occupied) drops the intent and releases the reservation. Verified by probe C below. | +| `SetStickerSetTitle` | `pack_handlers.go:591` | `getPack(ownerID)` found and `!Pending`; `Name` taken from that record | Owner-keyed read, same handler. Read happens *before* `lockUser` — hypothetical-concurrency only (see L2). | +| `SetStickerSetThumbnail` | `setpackicon.go:44` | `resolveOwned` → `ownsSet(pack, replied.Sticker.SetName)`; `Name` from the caller's own record | Slow media leg sits between check and call, but no dispatch point under serial dispatch. | +| `AddStickerToSet` | `sticker_handlers.go:69` | `getPack(ownerID)` found and `!Pending`; `UserID` is always the caller, `Name` from the caller's record | Same shape. | +| `DeleteStickerFromSet` | `sticker_handlers.go:122` | `resolveOwned` → `ownsSet(pack, st.SetName)` on the replied sticker | `st.SetName` and `st.FileID` come from the same Telegram-rendered `Sticker`; not client-forgeable. Old scrollback stickers from a deleted-then-reclaimed pack are stopped because the record is dropped alongside the set. | +| `SetStickerEmojiList` | `sticker_handlers.go:175` | `resolveOwned` | Same. | +| `SetStickerPositionInSet` | `sticker_handlers.go:214` | `resolveOwned` | Same. | + +**No deferred capability exists anywhere except `PendingDelete`.** Every other +command resolves authority and consumes it inside the same handler invocation, +so the stale-authority shape found at `/delpack` has no sibling at +`/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon` +or `/renamepack`. I drove `/addsticker` and `/delsticker` end to end against a +record that had moved on; both refuse at `resolveOwned`/`getPack`. + +## 2. Attacks driven end to end (probe results) + +Probes were written as a temporary test file, run, and removed. + +| Probe | Setup | Result | +|---|---|---| +| **A** — record gone, confirmation alive | non-`Pending` pack + live `PendingDelete`, record deleted out from under it, victim seeded holding `mypack_by_testbot` | `methods = [editMessageReplyMarkup answerCallbackQuery]`. **0 `deleteStickerSet`.** Victim record intact. Allowlist holds. | +| **A2** — record moved to `Pending`, confirmation alive | same, record replaced with a `Pending` intent naming the same set | **0 `deleteStickerSet`.** | +| **C** — `/delpack` on a `Pending` record frees a name with a live set behind it, next claimant attacks | Bob's interrupted attempt created the set; Bob `/delpack` (frees `mypack`); Alice `/newpack mypack` | Alice: `[getMe getStickerSet sendMessage]`, reply `"That pack name is taken."` No record, no adoption. Alice's follow-up `/delpack`: `"You don't have a pack yet."`, 0 API calls. **`createPack`'s occupancy probe is the wall and it holds.** | +| **E** — `dropPackRecord` with a failing pending store | `pending.Delete` returns an error | Record deleted, reservation released, **confirmation survives**. This is the state that makes the allowlist's `!found` disjunct load-bearing in production. | +| **G** — can a `Pending` record coexist with a live confirmation via handlers alone? | `/delpack` (prompt live) then `/newpack second Two` | Refused at the precheck: `"You already have a pack (mypack)."` Not reachable through handlers — but *is* reachable after an E-style failed clear. | +| **Concurrency** — 3 goroutines (`handleDelPackCallback` + `handleDelPack` + `handleAddSticker`) × 50 iterations × 3 runs, `-race` | | No races, never more than one `deleteStickerSet`. | + +Dispatch model re-confirmed serial: `internal/telegram/client.go:27-28` +(`WithSkipGetMe`, `WithNotAsyncHandlers`), no `WithWorkers` anywhere, so the +library default of one worker applies. All concurrency observations below are +labelled hypothetical. + +## 3. Mutation testing + +Backup → mutate → `go test ./internal/modules/sticker/` → restore. + +| # | Mutation | Outcome | Killing test | +|---|---|---|---| +| 1 | Allowlist reverted to the R5 blocklist (`found && current.Pending && ownsSet(...)`) | **KILLED** | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` (`delpack_callback_test.go:286`) — *only* this one | +| 2 | `dropPendingDelete` removed from `dropPackRecord` | **KILLED** | `TestDropPackRecord_ClearsAnOutstandingConfirmation` (`pack_handlers_test.go:874`) — *only* this one | +| 3 | Resume returns `existing` verbatim (both carry-overs removed) | **KILLED** | `TestNewPack_ResumeUsesTheTitleJustTyped` | +| 3b | Only `resumed.Name = intent.Name` removed | **SURVIVED** | — | +| 4 | `releaseSlug` removed from `resolveStaleIntent`'s `isStickerSetMissing` branch | **KILLED** | `TestNewPack_DifferentSlugReplacesDeadIntent` (`pack_handlers_test.go:171`) | +| 5 | Allowlist disjunct `!found` dropped (`_ = found`) | **SURVIVED** | — | +| 6 | Allowlist disjunct `current.Pending` dropped | **SURVIVED** | — | +| 7 | Allowlist disjunct `!ownsSet(current, action.SetName)` dropped | **KILLED** | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` | +| 8 | Mutations 1 **and** 2 together | **KILLED** | all three of `StalePressLeavesTheCurrentPackAlone`, `StalePressCannotDeleteTheNextHolder`, `DropPackRecord_ClearsAnOutstandingConfirmation` | + +Gates: `go build ./...` OK · `go test ./... -race -count=20` OK · +`golangci-lint run ./...` → `0 issues.` · `gofmt -l .` → clean. + +--- + +## Findings + +### H1 — The flagship round-6 test does not exercise the round-6 guard (High, test integrity) + +`TestDelPackCallback_StalePressCannotDeleteTheNextHolder` +(`internal/modules/sticker/delpack_callback_test.go:411-453`) is documented as +the regression test for the allowlist. It is not. + +Reproduction (mutation 1, in isolation): + +``` +$ # revert the allowlist to the R5 blocklist, nothing else +$ go test ./internal/modules/sticker/ -run TestDelPackCallback_StalePressCannotDeleteTheNextHolder -v +--- PASS: TestDelPackCallback_StalePressCannotDeleteTheNextHolder (0.00s) +``` + +Cause: at line 434 the test calls + +```go + // U's set vanishes at Telegram; a self-heal clears the record and the name. + s.dropPackRecord(ctx, testUser) +``` + +`dropPackRecord` now (change 2) deletes the `PendingDelete` as well, so the +callback returns at `delpack_callback.go:124` +(`pending.Get` → `storage.ErrNotFound` → `"This confirmation expired or was +already used."`) roughly fifty lines before the allowlist at line 193. The test +proves change 2, and only change 2 — which is already proven by +`TestDropPackRecord_ClearsAnOutstandingConfirmation`. + +Mutation 2 in isolation also leaves this test **passing** (the allowlist then +catches it). Only the double revert (mutation 8) fails it. A test that requires +both defences to be removed before it fires cannot detect either one +regressing. + +This is the fourth consecutive round in which a test was named for a behaviour +a structurally earlier guard prevents it from reaching. + +**Fix:** seed the state directly instead of routing through `dropPackRecord` — +`s.store.Delete(ctx, packKey(testUser))` and leave the confirmation in place — +so the press actually arrives at the under-lock re-check. Probe A above is a +working version of that test; it passes on `HEAD` and fails under mutation 1. + +### H2 — The `!found` disjunct is unpinned, and the state it guards is production-reachable (High) + +Mutation 5 (`_ = found; if current.Pending || !ownsSet(current, action.SetName)`) +survives the entire suite. That disjunct is the exact half of the R5 bug the +commit message calls out first ("fell through on the two that mattered: no +record at all"). + +It is not dead code. `dropPackRecord` logs and continues when the pending +delete cannot be removed: + +```go +func (s *state) dropPendingDelete(ctx context.Context, key string) { + commitCtx, cancel := commitContext(ctx) + defer cancel() + if err := s.pending.Delete(commitCtx, key); err != nil && !errors.Is(err, storage.ErrNotFound) { + log.Error("sticker_drop_pending_delete", "err", err) + } +} +``` + +Probe E confirms the resulting state on `HEAD`: pack record gone, reservation +released, confirmation still live and pressable. Probe A confirms `!found` is +what refuses the press in that state, and that without it the press lands on +whoever holds the name now. A single Mongo write failure is enough to enter it. + +**Fix:** add the probe-A test (record deleted directly, confirmation left +alive, victim seeded under the same name, assert zero `deleteStickerSet`). + +### H3 — The `current.Pending` disjunct is unpinned (Medium-High) + +Mutation 6 survives. Reachable in production by composing H2 with a normal +`/newpack`: once a failed `dropPendingDelete` has left a confirmation alive +with no record, `/newpack` passes the precheck and writes a fresh `Pending` +intent. A `Pending` record is bookkeeping written *before* Telegram is called — +`handleDelPack` and `TestDelPack_PendingRecordDeletesNothingAtTelegram` both +say so explicitly — so it must never authorise a delete. Probe A2 shows the +guard works today; nothing pins it. + +**Fix:** add probe A2 as a test. + +### M1 — `resumed.Name = intent.Name` is unpinned and repoints the record at a different set under a bot rename (Medium) + +`pack_handlers.go`, `claimSlug` resume branch: + +```go + resumed := existing + resumed.Title = intent.Title + resumed.Name = intent.Name + return resumed, false, nil +``` + +Mutation 3b (removing only the `Name` line) survives the whole suite — the +title carry-over is the only half the new test covers, despite the `Name` line +being the only one of the two that changes *which set* is touched. + +Probe B, driven end to end: seed an interrupted attempt with +`Name = "mypack_by_oldbot"` (bot renamed in BotFather since), stub `getMe` → +`testbot`, re-run `/newpack mypack Title`: + +``` +probed name = "mypack_by_testbot" +created name = "mypack_by_testbot" +stored pack = {Slug:mypack Name:mypack_by_testbot ... Pending:false} +``` + +Before `b7803ce` the probe targeted `mypack_by_oldbot`. If the interrupted +attempt did create that set, the old behaviour answered `slugTaken` and cleaned +up; the new behaviour creates a second set and orphans the first with no local +record pointing at it and no route to reach it through the bot. The `mypack` +reservation stays held (same slug), so no cross-user damage — this is a +resource leak and a behaviour regression, not a security defect. + +It also directly contradicts the invariant `ownsSet`'s own doc comment states +(`setname.go:73-79`): "It deliberately does not re-derive the name from the +live bot username. Renaming the bot in BotFather is supported and leaves +existing set names untouched." The resume branch now re-derives it. + +**Fix:** either drop the `Name` carry-over (the title fix is what the commit +message describes; the `Name` line is unexplained scope), or keep it and add a +test that pins the intent under a changed username. As written it is an +unexplained, untested line inside a security-sensitive commit. + +### L1 — `ownsSet` uses Unicode case folding on a security comparison (Low, informational) + +`strings.EqualFold` applies simple Unicode folding, so +`ownsSet(Pack{Name: "mypack_by_testbot"}, "mypacKk_by_testbot")` (U+212A +KELVIN SIGN) returns **true** — verified by probe F. Not exploitable: Telegram +constrains sticker-set short names to `[A-Za-z0-9_]`, `validateSlug` forces +`^[a-z][a-z0-9_]{2,39}$`, and the only two inputs are a stored record name and +a Telegram-rendered `Sticker.SetName`. Recording it because the comment +justifies `EqualFold` on casing grounds alone and does not note the folding +surface it brings along. `strings.ToLower` comparison would be equally correct +and narrower. + +### L2 — Read-modify-write outside the lock in four handlers (Low, hypothetical concurrency) + +`state.go`'s corrected `lockUser` comment says the lock "stays because every +mutation here is a read-modify-write, which is wrong the moment dispatch stops +being serial." Four handlers do not honour that: + +- `handleDelPack` — reads the pack, then writes `s.pending`, with **no lock at + all** on the prompt path (the lock is taken only inside the `pack.Pending` + branch). +- `handleAddSticker`, `handleDelSticker`, `handleRenamePack` — `getPack` runs + *before* `defer s.lockUser(ownerID)()`, so the record they act on was read + outside the critical section. + +Only `handleNewPack` takes the lock first. Moot under +`WithNotAsyncHandlers` + one worker; flagged because the comment asserts a +property the code does not have, which is precisely the class of defect change +6 was written to fix. + +### L3 — `internal/keylock` package doc contradicts the dispatch model (Low, out of scope) + +`internal/keylock/keylock.go:6-8`: "The bot dispatcher runs each Telegram update +in its own goroutine". `internal/telegram/client.go:18-22` and +`internal/modules/dispatcher.go:124-126` both say the opposite, and change 6 +corrected `state.go` to match. Same wrong-reason-for-a-right-guard shape, one +package over. Not this commit's responsibility; worth a follow-up. + +--- + +## Previously closed classes — re-confirmed still closed + +| Class | Evidence | +|---|---| +| Post-wipe adoption | No adoption branch remains (`createPack` has only `occupied → refuse` / `missing → create` / `unknown → abort`). `TestNewPack_WipedStoreCannotAdoptSurvivingPack` passes; mutation of the occupancy branch is out of scope but the branch is asserted on directly. | +| Inconclusive probe then live set | `TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver` passes; the guard it defeated no longer exists (refusal is unconditional). | +| Pending record as delete authority | `handleDelPack` refuses to prompt for a `Pending` record and drops it locally; `TestDelPack_PendingRecordDeletesNothingAtTelegram` passes; the callback's `current.Pending` disjunct is a second wall (probe A2). | +| Name-burning DoS | Precheck ordering (record read before `reserveSlug`) intact; `TestNewPack_RefusedRunsClaimNoNames` and `TestNewPack_FreshReservationReleasedWhenClaimBails` cover it. | +| Cross-user `releaseSlug` | Ownership verified inside the operation, not the caller; `TestReleaseSlug_RefusesANameHeldBySomeoneElse` passes. | + +## Change 2 audit (`dropPackRecord` now writes `s.pending`) + +Every call site passes an owner the caller already owns — no cross-user aim is +possible: + +| Call site | `ownerID` source | +|---|---| +| `handleDelPack` (pending branch) | `senderID(msg)` | +| `handleRenamePack` (`isStickerSetMissing`) | `senderID(msg)` | +| `handleAddSticker` / `handleDelSticker` / `handleEditSticker` / `handleOrderSticker` / `handleSetPackIcon` (`isStickerSetMissing`) | `senderID(msg)` | +| `dropPackRecordIfSet` ← delpack callback success | `action.OwnerID`, and the callback already proved `query.From.ID == action.OwnerID` and loaded the action under the presser's own key | + +`senderID` additionally rejects bots, anonymous group admins +(`SenderChat != nil`) and `From.ID == 0`, so `pendingDeleteKey` can never be +built from the shared `GroupAnonymousBot` identity. Failure of the added write +is logged and non-fatal, leaving the record deleted and the confirmation alive +— the H2 state, which the allowlist covers. + +## Merge position + +`b7803ce` is a genuine, correct security fix and I would not block it on +correctness. What I do block on is the test claim: the commit ships a test +named for the guard it introduces, that guard can be fully reverted with the +test still green, and two of the guard's three load-bearing disjuncts have zero +coverage. Given five prior rounds where a false-clean was produced by exactly +this — a same-named test that never reaches the branch — the fix should not +land with its own regression detector inoperative. + +Blocking work is small and mechanical: replace `s.dropPackRecord(ctx, testUser)` +in `StalePressCannotDeleteTheNextHolder` with a direct `s.store.Delete`, and add +the probe-A2 variant. Both are ten-line changes and both fail on `HEAD` under +the corresponding mutation. + +M1 (`resumed.Name`) should be resolved before merge too — decided either way, +but not left as an untested, undescribed line in a commit about proving +authority. + +## Unresolved questions + +1. Is `resumed.Name = intent.Name` intentional, and if so what should happen to + a set stranded under the pre-rename name? The commit message describes only + the title fix. +2. Does Telegram reserve a deleted sticker set's short name? Plan note R11 + still marks this unverified, and `dropPackRecord`'s release-the-name + behaviour is documented as a no-op if it does. Unchanged by this commit. diff --git a/plans/reports/verify-260825-1720-sticker-round7.md b/plans/reports/verify-260825-1720-sticker-round7.md new file mode 100644 index 0000000..61c9460 --- /dev/null +++ b/plans/reports/verify-260825-1720-sticker-round7.md @@ -0,0 +1,173 @@ +# Sticker module — round 7 scoped verification + +- Branch `feature/sticker-pack-module`, HEAD `5e4fb0f`, Go 1.27, golangci-lint v2.13.1. +- Scope: only H1, H2/H3, M1 from `verify-260825-1700-sticker-round6.md`, plus a scan of `5e4fb0f` for anything new. The 16-call Telegram enumeration was NOT redone. +- All mutations were applied to working-tree copies, then restored from backup. Final state: `git status --short` empty, 27/27 md5sums match, `git diff HEAD --stat` empty. + +## Verdict + +**SAFE_TO_MERGE.** H1, H2/H3 and M1 are closed. The `!found` equivalent-mutant claim is correct and is itself test-pinned. Nothing unintended was found in the commit. + +## Mutation results + +Guard under test, `internal/modules/sticker/delpack_callback.go:197`: + +```go +if !found || current.Pending || !ownsSet(current, action.SetName) { +``` + +| # | Mutation | Result | Killing test | +|---|---|---|---| +| M1 | drop `!found` (`_ = found` added to compile) | **SURVIVED** | none — equivalent mutant, see below | +| M2 | drop `current.Pending` | **KILLED** | `TestDelPackCallback_StaleAuthorityNeverReachesTelegram/record_is_unconfirmed` | +| M3 | drop `!ownsSet(...)` | **KILLED** | `.../record_moved_on` **and** `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` | +| M4 | full revert to the round-5 blocklist (`if found && current.Pending && ownsSet(...)`) | **KILLED** | `.../record_gone`, `.../record_moved_on`, `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` | +| M5 | re-add `resumed.Name = intent.Name` (`pack_handlers.go`) | **KILLED** | `TestNewPack_ResumeKeepsTheStoredSetName` (`set name = "mypack_by_testbot", want the stored "mypack_by_oldbot"`) | +| M6 | drop `resumed.Title = intent.Title` (control, prior round's fix) | **KILLED** | `TestNewPack_ResumeUsesTheTitleJustTyped` (3 assertions) | +| M7 | drop `s.dropPendingDelete(ctx, key)` from the guard body | **SURVIVED** | none — cleanup, not authority; see Informational | + +### H1 — closed + +The replacement test is not vacuous. M4 (full guard revert) fails two of the three table +cases; every case therefore executes past `pending.Get` and reaches the under-lock +allowlist, which is exactly what the round-6 test did not do. Non-vacuity is further +proven by the *specificity* of M2 and M3: `record_is_unconfirmed` fails only when +`current.Pending` is removed, which means `ownsSet` returned **true** there — so the +record was really loaded and really matched, and the case is testing the Pending disjunct +and nothing else. Same argument for `record_moved_on` and `!ownsSet`. + +### H2/H3 — closed to the extent it can be + +`current.Pending` is now individually killed (M2), and `!ownsSet` by two tests (M3). +`!found` survives (M1) — correctly, as an equivalent mutant. + +### M1 (`resumed.Name`) — closed + +M5 is killed with a specific message. The control M6 confirms the sibling title +assertion was not weakened while the file was edited. + +## The `!found` equivalent-mutant claim — CONFIRMED + +Independently verified three ways, not just by the surviving mutation: + +1. **Source.** `getPack` (`internal/modules/sticker/pack.go:89-98`) returns a literal + `Pack{}` on *both* non-found paths (`ErrNotFound` and error), and the error path + returns early at the call site. So at the guard, `found == false` implies + `current == Pack{}` implies `current.Name == ""`. +2. **`ownsSet`.** `internal/modules/sticker/setname.go:80-82` returns false when + `pack.Name == ""`. Hence `!ownsSet(current, _)` is already true whenever `!found`, + and `current.Pending` is false, so the mutated expression is bit-for-bit identical + on every reachable input. +3. **The equivalence is itself pinned.** `internal/modules/sticker/setname_test.go:79` + asserts `ownsSet(Pack{}, "anything") == false`. This is the property the redundancy + depends on, so a future edit to `ownsSet` that breaks the equivalence — making + `!found` load-bearing and silently untested — fails a test rather than passing + quietly. This is the one thing that made the claim safe to accept rather than merely + plausible. + +Searched for a counterexample and found none: + +- **Can a missing record yield a non-empty `current.Name`?** No. Both miss paths in + `getPack` discard the decoded value and return the zero `Pack`. +- **Can `action.SetName` be empty?** It is written once, at + `delpack_callback.go:65` (`SetName: pack.Name`), from a record that is already proven + `found && !pack.Pending`. Even if a corrupt store record carried `Name == ""`, `ownsSet` + returns false for an empty `setName` too, so the guard refuses. Fail-closed either way, + and `DeleteStickerSet` is never reached with an empty name. + +Keeping `!found` with the comment is the right call: it costs nothing and the alternative +is a guard whose correctness silently depends on a helper's empty-string branch. + +## Correctness of the reverted `resumed.Name` (not just coverage) + +The divergence only exists after a BotFather rename: `makeSetName` is deterministic in +`(slug, username)` and the branch requires `existing.Slug == slug`, so `intent.Name != +existing.Name` is *only* possible when the bot's username changed between the interrupted +attempt and the retry. Both sub-cases were driven with a probe test: + +**(a) The interrupted attempt did create the set.** `createPack` probes +`GetStickerSet(existing.Name)`, finds it, drops the intent, releases the slug and answers +"name taken". The old set is stranded but the user is told. Refreshing the name instead +would have probed the *new* name, found it free, and created a second set — orphaning the +first one silently. The revert is the better behaviour here. + +**(b) The interrupted attempt never created the set.** Probe output — the resume path +really does send the stale name to Telegram: + +``` +PROBE getStickerSet name="mypack_by_oldbot" +PROBE createNewStickerSet name="mypack_by_oldbot" +``` + +Real Telegram refuses a create whose short name does not end in `_by_<current username>`. +This is the one place the revert costs something, so I drove it rather than assuming. +Injecting Telegram's actual refusal: + +``` +PROBE after refusal: found=false pack={} # intent dropped +PROBE reservation still held: false # slug released +PROBE reply="Telegram rejected that pack name. Use lowercase letters, digits and single underscores." +PROBE attempt2 createNewStickerSet name="mypack_by_testbot" +PROBE attempt2 stored = {Slug:mypack Name:mypack_by_testbot ... Pending:false} +``` + +`createRefused` (`errors.go:112-123`) already matches `PACK_SHORT_NAME_INVALID` / +`"invalid sticker set name"`, so the refusal is classified as proof-nothing-was-created, +the stale intent and reservation are torn down, and the very next `/newpack` succeeds under +the current username. **There is no permanent wedge** — the cost is one misleading error +message in a rename-plus-interrupted-attempt window. That is strictly cheaper than the +silent orphan in (a), so the revert is correct, not merely test-pinned. + +Answering the two specific questions asked: + +- *Stored `Name` never validated?* Every write of `Pack.Name` in production goes through + `makeSetName`, which errors on an empty username and enforces `maxSetNameLen`. A record + with an unvalidated or empty `Name` cannot be produced by this module, and if one + existed, both `ownsSet` and the create probe fail closed. +- *Stored `Name` belonging to a different slug?* Unreachable in this branch, which is + gated on `existing.Slug == slug`; a differing slug routes to `resolveStaleIntent`, which + re-reads the reservation before touching anything under the old name. + +## Scan of `5e4fb0f` for anything new or unintended + +Five files: two production (comment-only + one deleted line), two test, one report. + +- `delpack_callback.go`: **comment only**. No behaviour change. +- `pack_handlers.go`: one line deleted, replaced by a comment. Verified by M5/M6 that the + surviving `resumed.Title` assignment is unchanged and still pinned. +- No new production code, no new helper, no new abstraction, no `any` widening, no lint + suppression, no error swallowed. +- The replaced test dropped its seeding of the victim's *slug reservation*. That seeding + was never asserted on in the old test either, so no assertion was weakened — the victim's + pack record check is retained in every table case. +- No phantom tests: every new test is mutation-killed (M4, M5) except by design. +- No scope drift; nothing outside `internal/modules/sticker/` and `plans/reports/`. + +## Gates + +| Check | Result | +|---|---| +| `go vet ./...` | clean | +| `go test ./...` | all pass | +| `go test -race ./...` | all pass | +| `go test -race -count=20 ./internal/modules/sticker/` | ok, 87.3s, no races, no flakes | +| `golangci-lint run ./...` | `0 issues.` | +| `gofmt -l .` | empty | + +## Informational (non-blocking) + +1. **M7 survivor.** Nothing pins that the guard clears the stale `PendingDelete` before + refusing. Removing `s.dropPendingDelete(ctx, key)` from the guard body leaves the whole + suite green. This is *not* an authority hole — the guard still refuses on every + subsequent press, and the confirmation expires on its own — so it is cleanup hygiene, not + safety. Worth one assertion in the table (`pending.Get` returns `ErrNotFound` after the + refusal) if a future round touches this file; not worth blocking on. +2. **Misleading refusal text after a bot rename.** In case (b) above the user is told their + *pack name* is invalid when the real cause is that the bot was renamed. Cosmetic, rare, + self-healing on retry. +3. The `break_` field name in the table trips no linter under the project's config + (`0 issues.`), so it is left alone. + +## Unresolved questions + +None. diff --git a/plans/reports/verify-security-260825-1558-sticker-round4.md b/plans/reports/verify-security-260825-1558-sticker-round4.md new file mode 100644 index 0000000..4d6514b --- /dev/null +++ b/plans/reports/verify-security-260825-1558-sticker-round4.md @@ -0,0 +1,227 @@ +# Adversarial verification — sticker packs, round 4 + +Scope: round-4 fixes on `feature/sticker-pack-module` (whole module lives in one +commit `d831747`, so round-3→4 cannot be isolated by git; current state reviewed). +Method: source read + four constructed attacks executed against the real handlers +in a throwaway copy of the repo, plus a 45s fuzz of the emoji splitter and an +image-pipeline probe. `go vet`, `go test ./...` (25 pkgs), `golangci-lint run` all +clean on the branch as committed. No repo file was modified. + +Verdict: **DO_NOT_MERGE** — change 1 does not close the takeover it was written for. + +--- + +## CRITICAL 1 — cross-user pack takeover still reachable; `freshReservation` is a per-invocation fact, not durable proof + +`internal/modules/sticker/pack_handlers.go:352` (guard), `:365-374` (the branch that +defeats it), `:289-305` (a second adopt branch that never consults the guard). + +The guard's premise (`:329`, `:340-351`) is "a genuine interrupted attempt always +re-enters having found its own reservation, never having made one". True. The +converse it relies on — "an attacker naming a foreign set can only ever be the one +who made the reservation" — is false, because the module deliberately **keeps** a +fresh reservation whenever the lookup is inconclusive: + +```go +default: + // Unknown. Keep both the intent and the reservation: the set may exist, + // and re-running is how the user recovers. + log.Error("sticker_newpack_lookup", "err", err) +``` + +One inconclusive `GetStickerSet` converts the attacker's fresh reservation into a +resumed one. The next invocation has `freshReservation == false` and adopts. + +**Precondition (all routes):** the victim's slug reservation is absent while the set +lives at Telegram. This is the exact state `docs/sticker-packs.md:122-127` documents +("what a restart does when no database is configured") and promises is safe: +"`/newpack` reports the name as taken rather than adopting a set it can no longer +prove is yours." With `KV_PROVIDER` auto-detect (`cmd/server/main.go:259-279`), any +deploy without `MONGO_URL` re-enters this state on **every restart**. + +**Route A — two ordinary commands, no crash, no store error** (executed, reproduced): + +1. Attacker sends `/newpack <victimslug> X`. `getStickerSet` answers 429 / 5xx / + deadline-exceeded — not `STICKERSET_INVALID` — so the `default` branch keeps the + attacker's intent *and* reservation. User sees "Something went wrong." +2. Attacker sends the identical command again. `reserveSlug` conflicts, holder is the + attacker → `created=false` → `createOrAdopt(..., freshReservation=false)` → set + exists → **adopted**. + +Observed reply: `Finished an earlier attempt at Mine. https://t.me/addstickers/mypack_by_testbot`, +record `{Slug:mypack Name:mypack_by_testbot OwnerID:999 Pending:false Count:1}`. +The attacker now holds `/delpack` (irreversible `DeleteStickerSet`), `/renamepack`, +`/setpackicon`, `/delsticker` over the victim's set — all keyed by set name with no +owner scoping. + +Step 1 is attacker-inducible, not luck: Telegram 429s are per-bot and any user can +provoke them, and after changes 3+4 the tail budget left for `GetStickerSet` is +~3 s (`FetchContext` reserve), so a slow media leg alone produces +`context deadline exceeded` → same `default` branch. + +**Route B — `resolveStaleIntent` never checks the guard at all** (executed, reproduced). +With intent + reservation for `<victimslug>` surviving (e.g. a crash at the refusal +point, before `dropIntent`), the attacker runs `/newpack <anyothername>`; +`claimSlug` → `resolveStaleIntent` re-proves only *who holds* the old reservation, +finds the victim's set, and adopts at `:292-304`. Reply: "You already have a pack +(mypack) from an earlier attempt — it has been restored." + +**Route C — partial refusal cleanup** (executed, reproduced). `:359-361` is two +independent, error-swallowing writes. If `releaseSlug` fails or the process dies +between them, the attacker keeps the reservation and the next attempt adopts (Route A +step 2 without needing step 1). + +**Not exploitable (checked):** `dropIntent` is always keyed to `pack.OwnerID`, which +is always the caller (`claimSlug` builds the intent, or `getPack(caller)` returns it); +`releaseSlug` re-verifies the holder at `:204`. **User A cannot drop user B's intent +or reservation.** That part of round 4 holds. + +**Fix (prototyped and verified).** Replace the per-invocation inference with durable +positive evidence: a `Probed bool` on `SlugReservation`, set only when +`GetStickerSet` positively answers `STICKERSET_INVALID` for a reservation this owner +holds, written *before* `CreateNewStickerSet`, and required by **both** adopt +branches. Fails closed: if the flag write fails, abort before creating. + +```go +// createOrAdopt, err == nil branch +if freshReservation || !s.probedClear(ctx, pack.OwnerID, pack.Slug) { ...refuse... } + +// createOrAdopt, isStickerSetMissing branch, before CreateNewStickerSet +if !s.markProbed(ctx, pack.OwnerID, pack.Slug) { return reply(ctx, b, msg, genericFailure) } + +// resolveStaleIntent +case err == nil && !held.Probed: // refuse: release old reservation, take the new intent +``` + +With this applied, all four attack routes refuse and the entire existing suite stays +green — one fixture needs updating (`seedInterrupted` in `pack_handlers_test.go:394` +must seed `Probed: true`, since it models a post-probe interrupted attempt). + +--- + +## MEDIUM 2 — the slug-ownership check still runs *after* the media pipeline + +`pack_handlers.go:94` (`resolveSource`) precedes `:109` (`reserveSlug`). + +Change 3's comment (`:81-84`) says making a user who cannot create a pack pay for the +full media pipeline "was free work for anyone who wanted to spend the bot's CPU" — +but that is still exactly what happens when the slug belongs to someone else. +Executed: `/newpack <slug-held-by-another-user>` replying to a photo issued `getFile` +and the file download before any slug check; on a decodable image it also resamples +and `UploadStickerFile`s. Methods recorded: `[getFile x.jpg sendMessage]`. + +Impact, single dispatch worker (`WithNotAsyncHandlers`, one worker): each such message +occupies the bot for the whole leg, and the attacker never acquires a pack so the new +`Pending` precheck never starts refusing them — the loop is unbounded. Measured +`toStickerPNG` cost on the single worker: 0.71 s for a flat 4096×4096 source (360 KB +on the wire) and 3.7 s for a noisy one (the ladder rungs). `mediaContext` does not +bound this — `toStickerPNG` takes no context and cannot be interrupted. Wasted +`UploadStickerFile` calls also burn the bot's API quota, which is the 429 that +Finding 1 step 1 needs. + +Fix: a read-only `getSlugReservation` before `resolveSource` — refuse when held by +another user. Read-only, so it does not reintroduce the round-1 name-burning DoS +(the create-only `reserveSlug` write stays where it is). + +--- + +## MEDIUM 3 — detached commit contexts are not actually protected at shutdown + +`state.go:53-61`, `cmd/server/main.go:220-227`, `:125`. + +Change 2 extends `context.WithoutCancel` to the reads, on the stated grounds that "a +commit that records a completed Telegram-side action must not be lost because the +process is shutting down". `main` does not honour that: on SIGTERM it cancels +`rootCtx`, calls `srv.Shutdown` on the health server (returns in ms with no live +connections), then returns — running `defer closeProvider()`, which disconnects Mongo. +Nothing waits for the in-flight inline handler. The detached context survives +cancellation but the process does not wait for the write, so a deploy can still lose +the commit that these comments promise is safe. + +Fix: track in-flight dispatch with a `sync.WaitGroup` (or a drain deadline) before +`closeProvider`, or downgrade the comments to "best effort". + +--- + +## LOW 4 — `downloadTimeout` is now dead + +`download.go:25,39` vs `state.go:78-80`. `FetchContext` reserves 3 s of a 10 s +handler, so `mediaCtx` is ≤ ~7 s and the 8 s `http.Client.Timeout` can never bind. +The comment still calls it "this module's own ceiling". Either lower it to match the +real budget or say it is a backstop for a caller with no deadline. + +## LOW 5 — `lockUser`'s stated rationale is not true for this module + +`state.go:82-84` claims "the cron scheduler and the detached per-command stats hook +run concurrently with them, so this is load-bearing". Grepped: `keylock` here is +`state`-local, the sticker module registers no cron jobs (`sticker.go:21-88`), and the +stats hook (`dispatcher.go:82-90`) touches only the stats collection. Nothing else +acquires these keys, and all sticker handlers run inline on the single dispatch +goroutine. Keep the lock (cheap, correct if dispatch ever goes async) but fix the +claim. Corollary: change 3 cannot deadlock or block the worker — verified, see below. + +## LOW 6 — nested `commitContext` re-arms the budget + +`pack_handlers.go:483` passes an already-detached ctx into `dropPackRecord:520`, which +derives another. `context.WithoutCancel` drops the parent deadline, so the inner +helper gets a fresh 5 s, and `dropPackRecord` → `releaseSlug` adds a third. A +`/delpack` confirm can therefore spend ~15 s in detached cleanup. No leak (every +`cancel` is deferred) and every op is bounded; just not the 5 s the constant implies. + +## LOW 7 — recording bot truncates instead of rejecting oversized bodies + +`testutil/recording_bot.go:195` reads through `io.LimitReader(r.Body, 8<<20)`; a body +above the cap is silently truncated and then fails `ParseMultipartForm` as a confusing +"bad multipart form: unexpected EOF" rather than a size error. No current test is near +the cap. Reading one extra byte and reporting "body too large" would match the +module's own `downloadFile:75` pattern. + +## LOW 8 — duplicated refusal text + +`pack_handlers.go:89-91` and `:248-250` build the same "You already have a pack" reply +independently. One helper; they will drift. + +--- + +## Attacked and held + +- **Cross-user destruction of state.** `releaseSlug` re-reads and compares the holder + (`:204`) and `dropIntent` is always keyed by the caller's own id. Probed both adopt + refusal paths: user A cannot drop user B's intent or reservation. Held. +- **Deadlock / worker starvation from change 3.** `WithNotAsyncHandlers` + + one worker (`internal/telegram/client.go:26-30`) means all sticker handlers run + serially on one goroutine; the `keylock.Map` is `state`-local; no cron, no hook, no + detached goroutine touches it; no handler nests a second `lockUser`. Held (the lock + is uncontended today). +- **`Pending` record behaviour after moving the precheck.** The precheck refuses only + `found && !existing.Pending`, so a pending user still falls through to the same + `claimSlug` resume path as before. No behaviour change. Held. +- **Change 5, extreme aspect ratios.** Executed 4096×1, 1×4096, 4096×3, 1×1, + 4096×4096: outputs 512×1, 1×512, 512×1, 512×512, 512×512 — no zero dimension, long + edge exactly 512, and the ladder's targets still derive from the *original* bounds + so the aspect is identical to the one-step version. Thumbnail path stays 100×100. + Held. +- **Change 6, emoji clustering.** 45 s / 526k-exec fuzz over valid UTF-8: no panic, no + infinite loop, no empty cluster, and nothing dropped except leading/trailing ZWJ and + whitespace. Spot-checked the singleton table against the standard non-block emoji + set (©, ®, ‼, ⁉, ™, ℹ, Ⓜ, ⤴, ⤵, 〰, 〽, ㊗, ㊙) — complete. Held. +- **Change 7, other modules' tests.** 28 files reference `NewRecordingBot`; full + `go test ./...` green. The library always sends `multipart/form-data` with a + boundary header and only omits the body for nil-param methods + (`raw_request.go:29-72`), so "empty body ⇒ skip parse, non-empty ⇒ must parse" is + the correct split, and replacing `r.Body` after buffering works because the boundary + comes from the unchanged header. Held. +- **`/delpack` callback authorisation.** Lookup keyed by presser, owner compared, + chat+message binding before any side effect, action consumed before the destructive + call. Held. +- **Token leakage.** `classify` inspects error *types* only; `errDownloadFailed` + replaces every download error; `replyErr` echoes only `userError`. Held. + +## Unresolved questions + +1. Is production on Mongo or on the auto-detected memory backend? Finding 1 is routine + after any restart on memory, and needs data loss on Mongo. It should be fixed either + way, but this decides whether it blocks the deploy or only the config. +2. `docs/sticker-packs.md:122-127` states the wiped-store refusal as a guarantee. It is + currently only true for the first attempt; the doc needs no change once Finding 1 is + fixed, but it should not ship as-is. diff --git a/plans/reports/verify-tests-260825-1558-sticker-round4.md b/plans/reports/verify-tests-260825-1558-sticker-round4.md new file mode 100644 index 0000000..914a65b --- /dev/null +++ b/plans/reports/verify-tests-260825-1558-sticker-round4.md @@ -0,0 +1,259 @@ +# Round-4 Verification — sticker module (adversarial, mutation-driven) + +Date: 2026-08-25 · Branch `feature/sticker-pack-module` @ `30fa3b3` (identical to +`origin/feature/sticker-pack-module`) · Go 1.27.0 linux/arm64 · golangci-lint v2.13.1 + +Method: every claim tested by mutating source and re-running the package, not by +reading. 34 mutations applied and reverted. All files restored byte-identical +(md5 verified, `git status --short` empty). + +## Verdict + +**All six round-4 claims verified.** No claim refuted. But three *previously +claimed* guarantees are unpinned and one test name is still misleading (the same +class of defect as round 3's `...SurvivesABail`). None of this is a new production +defect; it is test-coverage overstatement. + +## 1. Mutation results + +### Round-4 claims under test + +| # | Mutation | Result | Killing test | +|---|---|---|---| +| M1a | `handleNewPack`: delete `if created { s.releaseSlug(...) }` | **KILLED** | `TestNewPack_FreshReservationReleasedWhenClaimBails` | +| M1b | `handleNewPack`: make the release unconditional | **KILLED** | `TestNewPack_ResumedReservationNotReleasedWhenClaimBails` | +| M2 | `createOrAdopt`: remove the `if freshReservation` adoption gate | **KILLED** | `TestNewPack_WipedStoreCannotAdoptSurvivingPack` | +| M3 | `handleAddSticker`: early `return nil` (handler is a no-op) | **KILLED** | `TestAddSticker_EmojiPrecedence` (+both subtests), `TestAddSticker_FallsBackToDefaultEmoji`, +6 others | +| M3b | invert precedence: source emoji beats explicit args | **KILLED** | `TestAddSticker_EmojiPrecedence/explicit_wins` | +| M3c | drop source-emoji inheritance | **KILLED** | `TestAddSticker_EmojiPrecedence/inherits_from_replied_sticker` | +| M4 | remove `"sticker": sticker.New` + import from `factories()` | **KILLED** | `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` — and by the **reverse check specifically**: 9 lines `command_menu_test.go:133: /<cmd> is expected but not registered` | +| M5 | `recording_bot.handle`: tolerate any multipart parse failure (`if err == nil`) | **KILLED** | `TestRecordingBot_RejectsMalformedMultipart` | +| M6a | `isBinding`: disable tag-block (`E0020..E007F`) binding | **KILLED** | `TestParseEmoji_ClusterEdgeCases/tag_sequence_flag` | +| M6b | `splitClusters`: ZWJ absorbs the next rune unconditionally | **KILLED** | `.../joiner_before_a_flag` | +| M6c | `trimJoiners` → identity | **KILLED** | `.../joiner_before_a_flag`, `.../trailing_joiner` | +| M6d | `isEmojiCluster`: accept a lone regional indicator | **KILLED** | `.../lone_regional_indicator`, `.../odd_regional_indicator_count` | +| M6e | drop each of the 9 new `emojiSingletons` entries, one at a time | **9/9 KILLED** | `.../copyright`, `/registered`, `/arrow_curving_up`, `/arrow_curving_down`, `/circled_m`, `/wavy_dash`, `/part_alternation`, `/japanese_congratulations`, `/japanese_secret` | + +Claims 1-6: **verified, all directions.** Claim 1's two-direction pinning is real — +M1a and M1b are killed by *different* tests, and by only one test each. + +### Additional adversarial mutations (not claimed, run to find gaps) + +| # | Mutation | Result | Killing test | +|---|---|---|---| +| M8 | `createOrAdopt` unknown-error branch releases the slug | KILLED | `TestNewPack_UnknownLookupErrorAborts`, `TestNewPack_ResumedReservationSurvivesABail` | +| M10 | `releaseSlug`: drop its **own** ownership check | **SURVIVED** | — | +| M11b | `resolveStaleIntent`: skip the reservation-owner re-proof | KILLED | `TestNewPack_StaleIntentCannotAdoptForeignName` | +| M12b | `reserveSlug`: treat another user's reservation as resumable | KILLED | `TestNewPack_CannotSeizeAnotherUsersPack`, `TestNewPack_ForeignReservationRefusedBeforeAnyAPICall` | +| M13 | refused adoption leaves intent + reservation behind | KILLED | `TestNewPack_WipedStoreCannotAdoptSurvivingPack` | +| M14 | `releaseSlug`: read reservation on request ctx, not `commitContext` | **SURVIVED** | — | +| M16b | delpack callback: drop `query.From.ID != action.OwnerID` | **SURVIVED** | — | +| M17 | delpack callback: drop the message-binding check | KILLED | `TestDelPackCallback_RejectsWrongBinding` (+subtests), `TestDelPackCallback_BystanderCannotTouchAnotherUsersPrompt` | +| M18 | delpack callback: drop the expiry check | KILLED | `TestDelPackCallback_RejectsExpired` | +| M19b | delpack callback: drop the `action.ID != id` nonce check | **SURVIVED** | — | +| M6e' | drop each pre-existing singleton `203C`, `2049`, `2122`, `2139` | **4× SURVIVED** | — | +| M7 | drop each `emojiRanges` entry, one at a time | 3 KILLED (`1F300`, `2600`, `2B00`) / **5 SURVIVED** (`1F000`, `2190`, `2300`, `25A0`, `1F1E6`) | — | + +**Score: 29 killed / 34 mutation slots, 12 survivors across 5 distinct sites.** + +## 2. Emoji differential probe (full rune space) + +Temp in-package test walked `0x0..0x10FFFF` comparing `isEmojiRune` against a +reconstructed round-3 switch (same 8 blocks + the 4 pre-existing singletons). +`emoji.go` exists in exactly one commit (`d831747`) on this branch and nowhere in +history/reflog/other branches, so the old switch could not be recovered verbatim +— it was reconstructed from the range table plus the round-3 correctness report +(`correctness-review-260825-1515-sticker-module.md:116-131`, which enumerates the +9 refused codepoints and names `2122`/`2139` as already special-cased). + +``` +deliberate additions observed: 9 of 9 -> [U+00A9 U+00AE U+24C2 U+2934 U+2935 U+3030 U+303D U+3297 U+3299] +UNEXPECTED classification changes: 0 +``` + +**Result: zero unexpected classification changes.** The switch→array+map +restructure is behaviour-preserving modulo exactly the 9 intended additions. + +Second, non-circular probe against authoritative Unicode data +(`unicode.org/Public/UCD/latest/ucd/emoji/emoji-data.txt`, 1288 lines): + +``` +accepted-but-not Emoji/Extended_Pictographic: 2140 (e.g. U+2190..U+21FF arrows, U+25A0.. shapes) +Emoji-property runes rejected: 12 -> [U+0023 U+002A U+0030..U+0039] +``` + +The 12 rejections are the keycap bases, handled by the `keycapCombining` branch in +`isEmojiCluster` — not a defect. The 2140 false positives are the documented +block-approximation trade-off (`emoji.go` comment: "Deliberately ranges rather +than a property lookup"), pre-existing and low impact: Telegram answers +`STICKER_EMOJI_INVALID` and `apiRefusal` renders a sane message. Not a round-4 +regression. Informational only. + +## 3. New / still-wrong tests found + +### F-1 (MEDIUM) — `TestNewPack_ResumedReservationSurvivesABail` still does not test what its name and comment claim + +`pack_handlers_test.go:573`. Its comment says *"A reservation the caller merely +resumed must not be released when a later step bails — it predates this command."* +That is the `created`-flag distinction. Proven false by mutation: + +- under **M1b** (release made unconditional — i.e. the resumed/fresh distinction + deleted outright) this test still passes: `ok ... 0.019s`. Only + `TestNewPack_ResumedReservationNotReleasedWhenClaimBails` catches M1b. +- what it *actually* pins is M8: `createOrAdopt`'s unknown-error branch must not + release. So does `TestNewPack_UnknownLookupErrorAborts`, which M8 also killed. + +It is a duplicate of `TestNewPack_UnknownLookupErrorAborts` wearing the name of the +round-4 test that replaced it. Round 4 correctly added the two real tests but left +the misnamed one in place. Rename to `TestNewPack_UnknownLookupErrorKeepsTheReservation` +or delete it. + +### F-2 (MEDIUM) — `TestDelPackCallback_RejectsOtherUser` does not exercise the owner check + +`delpack_callback_test.go`. **M16b survived**: deleting +`if query.From.ID != action.OwnerID` from `delpack_callback.go:118` breaks no test. +Reason: the record is fetched with `key := pendingDeleteKey(query.From.ID)` (`:108`), +so a foreign presser has no record at all and exits three lines earlier via +`ErrNotFound` → *"This confirmation expired or was already used."* The owner check +is structurally unreachable; the test named for it passes through a different +branch. Authorization is genuinely enforced (by the key), so this is a test-naming +and dead-code issue, not a hole. Fix: assert the *reply text*, or drop the +unreachable branch. + +### F-3 (LOW) — `releaseSlug`'s own ownership check is unpinned + +**M10 survived.** The code comment (`pack_handlers.go:176-181`) states the check +exists precisely because *"this module has already been bitten once by an ownership +check that lived in the caller instead of the operation."* Nothing tests it. Every +current call site happens to pass the correct owner, so the check is presently +redundant — which is exactly why a future call site could silently regress it. + +### F-4 (LOW) — round-3's `commitContext` fix in `releaseSlug` is unpinned + +**M14 survived.** Moving the ownership read back onto the request context breaks +nothing: there is no cancelled-context test in the package (`grep context.WithCancel +internal/modules/sticker/*_test.go` → 0 hits). The comment at `:183-190` describes a +concrete failure mode (SIGTERM mid-release leaves an unreachable reservation) with +no test behind it. + +### F-5 (LOW) — delpack nonce check unpinned + +**M19b survived.** Acknowledged in-code as defence-in-depth subsumed by the +message binding (`:141-143`), so lower priority than F-2, but it is untested +dead weight. + +### F-6 (LOW) — dead `emojiRanges` entry with a load-bearing-sounding comment + +`{0x1F1E6, 0x1F1FF}` is fully contained in `{0x1F000, 0x1F2FF}` — **M7 confirms both +survive individual deletion**. Its comment ("regional indicators; isEmojiCluster +requires a pair") reads as though it is required. It is not. Same for the untested +`2190`, `2300`, `25A0`, `1F000` entries and the four pre-existing singletons +(`203C`, `2049`, `2122`, `2139`) — all silently deletable. + +### No vacuous-loop assertions remain + +Swept every `for _, call := range rb.Sent()` in the changed packages. The four hits +are either negative assertions (correct as loops), counting helpers, or already +gated by a preceding `countMethod(...) != 1` fatal. Round 4's `addedStickerPayload` +helper is genuine — M3 proves it. Also scanned all 89 sticker tests + the new +dispatcher/testutil/cmd tests for zero-assertion bodies: 4 heuristic hits, all +false positives (the assertion is a `Fatalf` on `err == nil`). + +## 4. Regression sweep — `internal/testutil/recording_bot.go` + +26 test files across 18 packages construct a `RecordingBot`. All pass. + +The behaviour change (non-empty body that fails multipart parse → 400 instead of +200-with-empty-form) is scoped correctly: + +- `go-telegram/bot@v1.20.0 raw_request.go:28-71` always sends multipart; when + `params == nil` (parameterless methods) it skips both `buildRequestForm` and + `form.Close()`, so the body is **zero bytes** — the `len(body) > 0` gate is the + right discriminator. `TestRecordingBot_ServesParameterlessCall` confirms getMe. +- The exact hazard the comment warns about exists in the repo: + `internal/modules/stock/dividend_flow_test.go:115` and `:139` assert + `Form["reply_markup"] != ""` is false. Both still pass — the change *protects* + them rather than breaking them. +- No caller asserts on a field only present under a file part. + +`go test -race -count=1 ./...` — clean, no races, exit 0. +`go test -count=20 ./internal/modules/sticker/` — `ok ... 7.964s`, no flakes. + +Nit (non-blocking): `recording_bot.go handle()` carries two overlapping comment +paragraphs saying the same thing — a stale round-3 paragraph left above the +round-4 one. + +## 5. Gates + +``` +golangci-lint run ./... → 0 issues. +gofmt -l . (minus third_party) → (empty) +go test -race -count=1 ./... → clean +``` + +## 6. Final state (verbatim) + +``` +$ git status --short +$ +``` +(empty) + +md5 of every tracked file diffed against the pre-review baseline: **MD5 IDENTICAL**. + +``` +$ go test ./... +ok github.com/tiennm99/miti99bot/cmd/server (cached) +ok github.com/tiennm99/miti99bot/internal/cron (cached) +ok github.com/tiennm99/miti99bot/internal/deploynotify (cached) +ok github.com/tiennm99/miti99bot/internal/keylock (cached) +ok github.com/tiennm99/miti99bot/internal/log (cached) +ok github.com/tiennm99/miti99bot/internal/metrics (cached) +ok github.com/tiennm99/miti99bot/internal/modules (cached) +ok github.com/tiennm99/miti99bot/internal/modules/amlich (cached) +ok github.com/tiennm99/miti99bot/internal/modules/coin (cached) +ok github.com/tiennm99/miti99bot/internal/modules/gold (cached) +ok github.com/tiennm99/miti99bot/internal/modules/lol (cached) +ok github.com/tiennm99/miti99bot/internal/modules/loldle (cached) +ok github.com/tiennm99/miti99bot/internal/modules/misc (cached) +ok github.com/tiennm99/miti99bot/internal/modules/monkeyd (cached) +ok github.com/tiennm99/miti99bot/internal/modules/stats (cached) +ok github.com/tiennm99/miti99bot/internal/modules/sticker (cached) +ok github.com/tiennm99/miti99bot/internal/modules/stock (cached) +ok github.com/tiennm99/miti99bot/internal/modules/util (cached) +ok github.com/tiennm99/miti99bot/internal/modules/util/chathelper (cached) +ok github.com/tiennm99/miti99bot/internal/modules/wordle (cached) +ok github.com/tiennm99/miti99bot/internal/server (cached) +ok github.com/tiennm99/miti99bot/internal/storage (cached) +? github.com/tiennm99/miti99bot/internal/systemstate [no test files] +ok github.com/tiennm99/miti99bot/internal/telegram (cached) +ok github.com/tiennm99/miti99bot/internal/testutil (cached) +ok github.com/tiennm99/miti99bot/internal/testutil/mongotest (cached) +``` + +## 7. Recommended actions + +1. **F-1** — rename or delete `TestNewPack_ResumedReservationSurvivesABail`. Third + round running that a test in this file claims more than it proves; the name is + what misled round 3. +2. **F-2** — assert the reply text in `TestDelPackCallback_RejectsOtherUser`, or + remove the unreachable `query.From.ID != action.OwnerID` branch. +3. **F-3 / F-4** — add two small tests: a cross-owner `releaseSlug` call, and a + `releaseSlug` under a cancelled parent context. Both are ~10 lines and pin + fixes whose comments describe real past incidents. +4. **F-6** — delete `{0x1F1E6, 0x1F1FF}` from `emojiRanges` or fix its comment. +5. Drop the duplicated comment paragraph in `recording_bot.go handle()`. + +None of 1-5 blocks merge. All are test/comment hygiene against real production +behaviour that is correct today. + +## Unresolved questions + +- The pre-round-4 `isEmojiRune` switch is unrecoverable from git (single squashed + commit). The differential probe is therefore partly circular *for the singleton + set* — it is fully independent for the eight ranges. If the round-3 source is + available elsewhere, re-running the probe against the verbatim original would + close the last gap. +- Are the four pre-existing singletons (`203C ‼`, `2049 ⁉`, `2122 ™`, `2139 ℹ`) + intentionally unlisted in `TestParseEmoji_ClusterEdgeCases`, or an oversight when + round 4 added the nine?