From 9c3c5457f985a6312849c875bc09a2d5a504cd0b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 25 Aug 2026 09:32:22 +0700 Subject: [PATCH 01/11] docs(plans): add sticker packs module plan Plan for internal/modules/sticker: public, multi-pack-per-user Telegram sticker set management using @Stickers command names, single-shot reply+args instead of a conversational flow. Six phases, 117 tasks. Phase 1 covers two shared-code prerequisites the module would otherwise expose: no panic barrier on the update path, and a test harness that cannot return structured API results. Researched against the live Bot API and red-teamed by three adversarial reviewers; 16 findings accepted, recorded in plan.md. Notable corrections: - MODULES is not opt-in; an empty value loads every module, so the factories() entry is itself the enablement - getStickerSet exposing no owner does not force "orphans cannot be adopted"; a write-ahead intent record makes recovery sound - the file-download URL embeds the bot token and reaches the dispatcher log through url.Error, which logging file_id does not prevent - /packlist ran ten API calls under a 60s per-call ceiling, a worse stall than the photo pipeline the plan had been guarding - the /delsticker probe deleted a live pack's record on any transient error - /help has 884 runes of headroom for nine new commands --- .../phase-01-shared-prerequisites.md | 160 +++++++++ .../phase-02-store-setname-emoji.md | 185 ++++++++++ .../phase-03-pack-lifecycle.md | 224 ++++++++++++ .../phase-04-sticker-commands.md | 171 +++++++++ .../phase-05-photo-pipeline.md | 208 +++++++++++ .../phase-06-wiring-docs.md | 168 +++++++++ plans/260824-1051-sticker-pack-module/plan.md | 335 ++++++++++++++++++ 7 files changed, 1451 insertions(+) create mode 100644 plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md create mode 100644 plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md create mode 100644 plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md create mode 100644 plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md create mode 100644 plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md create mode 100644 plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md create mode 100644 plans/260824-1051-sticker-pack-module/plan.md 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..cf3de27 --- /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: todo +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 + +- [ ] `recover()` in the command closure with `metrics.IncError("handler-panic")` +- [ ] `recover()` in the callback closure with guarded `AnswerCallbackQuery` +- [ ] Fix the stale `recover()` comment at `dispatcher.go:167` +- [ ] `RecordingBot.StubMethod(method, resultJSON)` +- [ ] `RecordingBot.FailMethodCode(method, errorCode, description)` +- [ ] Document that `FailMethod` produces a codeless failure +- [ ] `dispatcher_panic_test.go`: panicking command handler +- [ ] `dispatcher_panic_test.go`: panicking callback handler +- [ ] `recording_bot_test.go`: stubbed `getStickerSet` decodes into `models.StickerSet` +- [ ] `recording_bot_test.go`: `FailMethodCode` yields `bot.ErrorBadRequest` + +## Success Criteria + +- [ ] A command handler that panics is recovered; the test process survives and the error metric increments +- [ ] A callback handler that panics is recovered and the callback query is still answered +- [ ] `rg "recover\(\)" internal/modules/dispatcher.go` returns two hits +- [ ] No comment in the repo claims a `recover()` exists in `webhook.go` +- [ ] `rb.StubMethod("getStickerSet", ...)` lets `b.GetStickerSet` return a populated `*models.StickerSet` with a nil error +- [ ] `rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")` produces an error satisfying `errors.Is(err, bot.ErrorBadRequest)` +- [ ] `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..05b4ae1 --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md @@ -0,0 +1,185 @@ +--- +phase: 2 +title: "Phase 2: Store, set names, emoji parsing" +status: todo +priority: P1 +effort: "4h" +dependencies: [1] +--- + +# Phase 2: Store, set names, emoji parsing + +## Overview + +Pure, Telegram-free foundation: the persisted pack record, the mapping between a +user-facing slug and a Telegram set name, sender validation, and emoji-argument parsing. +Every function here is unit-testable without a bot. + +## Requirements + +- Functional: persist one record per pack, keyed so a lookup by the caller's own user ID is + itself the ownership check. +- Functional: build Telegram set names of the form `_by_`, and match a + sticker's `set_name` against stored packs **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 one bot-created sticker set owned by a Telegram user. +type Pack struct { + Slug string `bson:"slug"` // user-facing id, e.g. "my_memes" + Name string `bson:"name"` // Telegram set name, "my_memes_by_botname" + Title string `bson:"title"` // display title + OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to + Count int `bson:"count"` // stickers in the set; keeps /packlist API-free + Pending bool `bson:"pending"` // write-ahead intent; see Phase 3 + CreatedAt int64 `bson:"createdAt"` // unix millis +} +``` + +`Count` exists so `/packlist` makes zero API calls (plan C2 / R1). It is maintained by +`/addsticker` and `/delsticker` and is explicitly **advisory** — it can drift if a user edits +the pack through @Stickers. Phase 3 documents how it self-heals. + +`Pending` implements write-ahead intent (plan C5). Phase 3 owns the state machine. + +- Key: `packKey(ownerID int64, slug string)` → `strconv.FormatInt(ownerID,10) + ":" + slug`. + `:` is legal — `internal/storage/keys.go:24-41` forbids only `/`, `.`, `..`, `__…__`, empty, + and >1500 bytes. +- `listPacks(ctx, ownerID)` → `store.List(ctx, ownerID+":")` then `Get` each; sort by slug. + The N+1 is structural: `mongo_doc_store.go:157-165` projects `_id` only. +- Ownership is structural: a command derives the key from the *caller's* ID, so a hit means + the caller owns the pack. +- `maxPacksPerUser = 10` (plan O2), checked before create. +- `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 namespace and one quota, and `CreateNewStickerSet` with a bot `user_id` would +fail with an unmapped error. `rg "IsBot" internal/` returns zero hits today — `coin`, `gold`, +and `stock` all check only `From != nil && From.ID != 0`, which is safe for paper-trading +state but not for durable Telegram objects. + +The refusal text should 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 40-char cap is for link + readability and title budget; it is **no longer** coupled to callback-payload size, since + Phase 3 puts an opaque id in the callback rather than a slug. +- `buildSetName(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". +- **`matchPack(packs []Pack, setName string) (Pack, bool)`** — case-insensitive comparison of + `setName` against each `pack.Name`. This is the ownership resolver used by Phase 4. + + It deliberately replaces the earlier `parseSlug(setName, botUsername)` design. That version + re-derived the slug from the *live* username and discarded the persisted `Pack.Name` + entirely — so renaming the bot in BotFather (a supported operation that leaves existing set + names untouched) would make every user's own packs refuse as "not yours", while `/packlist` + still listed them. It also let a case variant in `SetName` miss the key. Matching the stored + name fixes both (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** to build names for new packs — 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 + `modules.Build(nil, factories(), …, 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 = "⭐"` for +sources carrying none. + +Note `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: `internal/modules/sticker/pack_test.go`, `setname_test.go`, `sender_test.go`, + `emoji_test.go` +- 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: `go-telegram/bot@v1.20.0` `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, key helpers, `listPacks`, `maxPacksPerUser`. +2. `sender.go` — `senderID` with the bot/anonymous refusals. +3. `setname.go` — slug validation, `buildSetName`, `matchPack`, cached resolver interface. +4. `emoji.go` — cluster scanner and `defaultEmoji`. +5. Tests per the Todo list. + +## Todo + +- [ ] Define `Pack` incl. `Count` and `Pending`; assert no reserved-bson collision +- [ ] `packKey` and `listPacks` with owner-prefix scan +- [ ] `maxPacksPerUser` quota helper +- [ ] `senderID` rejecting nil/zero/`IsBot`/`SenderChat` with an explanatory message +- [ ] `slugRe` validation incl. `__`, trailing `_`, 40-char cap +- [ ] `buildSetName` with 64-char guard and budget-reporting error +- [ ] `matchPack` case-insensitive match against stored `Pack.Name` +- [ ] `usernameResolver` caching success but never failure, taking the handler's `b` +- [ ] `parseEmoji` cluster scanner with the 20-entry cap +- [ ] Four test files per the success criteria + +## Success Criteria + +- [ ] `listPacks` for owner A never returns owner B's packs +- [ ] Quota boundary tested at 9, 10, 11 packs +- [ ] Slug table rejects leading digit, `__`, trailing `_`, 2 chars, 41 chars +- [ ] `buildSetName` errors when `len(slug)+len("_by_"+username) > 64` +- [ ] `matchPack` matches `MyPack_by_Bot` against a stored `mypack_by_bot` +- [ ] `matchPack` returns false for a set name belonging to another bot +- [ ] A simulated bot username change does **not** break `matchPack` for existing packs +- [ ] `senderID` rejects `IsBot: true` and a non-nil `SenderChat`, each with zero store access +- [ ] `parseEmoji` handles joined input, ZWJ family, flag, keycap, skin tone; rejects plain text; errors above 20 +- [ ] `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.** A user editing their pack through @Stickers changes the real count +without the bot seeing it. Accepted: the field is advisory and only feeds a display column. +Phase 3 refreshes it opportunistically whenever a command already holds a `GetStickerSet` +response, so it self-heals without any command paying for a lookup it did not need. + +**The bot username is still a single point of failure for `/newpack`.** `matchPack` protects +existing packs from a rename (R8), but creation still builds `_by_`. +After a rename, old packs keep working and new ones use the new suffix — correct behaviour, +but worth stating so nobody "fixes" it later by rewriting stored names. 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..07be8e1 --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md @@ -0,0 +1,224 @@ +--- +phase: 3 +title: "Phase 3: Pack lifecycle commands" +status: todo +priority: P1 +effort: "8h" +dependencies: [1, 2] +--- + +# Phase 3: Pack lifecycle commands + +## Overview + +`/newpack`, `/packlist`, `/renamepack`, `/delpack` (+ confirm callback). These own pack +creation and destruction, 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 a sticker set on behalf of the caller and persist its record such that + no interruption can permanently strand the slug. +- Functional: list, rename, and delete the caller's own packs, never another user's. +- Functional: `/delpack` confirmation is bound to invoker, chat, message, and a TTL. +- Functional: `/packlist` 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), `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` can ship `/ff`. + +`handlerTimeout = 10 * time.Second` is a package constant; every handler opens with it. + +### `/newpack ` — write-ahead intent + +The earlier draft did "create on Telegram, then write the store", and accepted that an +interruption strands 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 actually 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. Quota check via `listPacks`. +4. `buildSetName(slug, username)`. +5. **`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`). `ErrConflict` means this owner already has a record for + this slug: if it is `Pending`, resume at step 6 (this is the retry path); if confirmed, + reply "you already have that pack". + Use `Put` nowhere here — it is a 5-attempt Get→PutVersioned loop + (`mongo_doc_store.go:120-142`) that silently overwrites. +6. `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 8. + If we did *not* hold a pending record we would not have reached here — step 5 would + have created one — so adoption is only ever of our own intent. + - **`isStickerSetMissing`** → the slug is free; proceed to step 7. + - **any other error** → unknown. Abort, delete the pending record, reply generic failure. + Never guess (plan rule 4). +7. `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 slug. +8. 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 now completes the operation +instead of reporting it taken. That is the plan's "interrupted `/newpack` can be completed by +re-running" success criterion. + +A pending record does consume a quota slot until resolved. Acceptable at +`maxPacksPerUser = 10`; a stale-pending sweep is a follow-up, not this phase. + +### `/packlist` — zero API calls + +`listPacks(senderID)` rendered with `chathelper.MonospaceTable` (slug, title, `Count`, link). +Pending records render with a "(incomplete — re-run /newpack)" marker rather than being +hidden, so a user can see and fix a stranded attempt. + +The earlier design 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 is a ~10-minute bot-wide freeze from one argument-free public command — +strictly worse than the photo pipeline the plan had been treating as its main risk. Counts +now come from `Pack.Count` (Phase 2). + +### `/renamepack ` + +Ownership via `store.Get(packKey(sender, slug))`. `SetStickerSetTitle{Name, Title}`, then +commit the new `Title` under `WithoutCancel`. The reply must state the share link is +unchanged — the Telegram short name is permanent. + +**Reverse gap (was undocumented):** if the API succeeds and the commit fails, `/packlist` +shows a title Telegram no longer has. Cosmetic only, self-heals on the next successful +rename. Documented rather than mitigated. + +### `/delpack ` — bound confirmation + +The earlier design 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 expires, is not bound to a chat or message, and lives in scrollback +forever. Alice cancels, recreates `memes` months later with 100 stickers, taps the stale +button, and it is destroyed with no fresh intent. Three reviewers flagged it independently, +and the `stock` module the draft cited as its model already solves this properly. + +Follow `stock` fully, not half of it: + +1. `/delpack` validates ownership, 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). +2. Callback data is `sticker_pack:d:` — an id, not a slug. Well under the 64-byte + cap (C3), and it decouples the payload from slug length entirely. +3. The callback handler: + - returns early when `update.CallbackQuery == nil`; + - resolves the pending action; absent → answer "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`) and is 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 the 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 (was undocumented):** if `DeleteStickerSet` succeeds and `store.Delete` fails, +a phantom record survives — consuming a quota slot, rendering in `/packlist`, and erroring on +every operation. This is worse than an orphan set, because the user cannot see why they are +at their limit. Mitigation: `/packlist` and every command that receives +`isStickerSetMissing` from the API deletes the offending record on the spot, so a phantom +self-heals on first contact. + +### 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: `. + +| 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` | that 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. `/packlist` 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 + +- [ ] `state.go` with store, pending view, resolver, locks, nowFn, `handlerTimeout` +- [ ] `sticker.go` factory registering 4 commands + callback prefix +- [ ] `errors.go`: `replyAPIError` code table + `isStickerSetMissing` +- [ ] `/packlist` reading `Count`, marking pending records, zero API calls +- [ ] `/newpack` steps 1-8 incl. `PutVersioned` intent and adoption +- [ ] `/renamepack` with permanent-link note and `WithoutCancel` commit +- [ ] `pending_delete.go` with TTL, chat/message binding, opaque id +- [ ] `/delpack` emitting the bound confirm keyboard +- [ ] `delpack_callback.go` with expiry, binding, nil-message guard, single-use +- [ ] Record self-heal on `isStickerSetMissing` across commands +- [ ] Tests per the success criteria + +## Success Criteria + +- [ ] `/packlist` records **zero** entries in `RecordingBot.Sent()` +- [ ] Interrupted `/newpack` (pending record present, set exists) completes on re-run and does not report the slug taken +- [ ] `/newpack` where `GetStickerSet` fails with a non-missing error aborts, deletes the pending record, and never calls `CreateNewStickerSet` +- [ ] `/newpack` uses `PutVersioned(…, 0, …)`; a second create attempt surfaces `ErrConflict` rather than overwriting +- [ ] Foreign-pack access replies with the ownership error and records zero API calls +- [ ] Quota exceeded at 11 packs blocks before any API call +- [ ] `/delpack` confirm after `ExpiresAt` is refused as expired, with no `DeleteStickerSet` +- [ ] `/delpack` confirm from a different `From.ID` is refused, with no `DeleteStickerSet` +- [ ] `/delpack` confirm with a nil `CallbackQuery.Message.Message` is handled without panic +- [ ] Pressing the same confirm twice deletes once; the second press reports already-used +- [ ] Callback data is asserted ≤ 64 bytes +- [ ] A command receiving `STICKERSET_INVALID` deletes the stale record +- [ ] Title of 65 chars rejected locally, before any API call + +## Risk Assessment + +**The write-ahead state machine is the most intricate logic in the plan.** Its correctness +rests on one property: a `Pending` record for `(owner, slug)` means *this owner* asked for +*this name*, and only this bot can create `*_by_` names. If either half stops +holding, adoption becomes unsafe. Signal: a user reports adopting a pack they did not create. +Response: disable adoption (step 6 becomes "slug taken") and fall back to the documented +orphan gap — a one-line change, deliberately. + +**A pending record consumes quota until resolved.** A user who abandons an interrupted +`/newpack` sees 9 usable slots. Visible in `/packlist` with its marker, and re-running the +command clears it. A sweep for pending records older than an hour is a follow-up. + +**`/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..822c35d --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md @@ -0,0 +1,171 @@ +--- +phase: 4 +title: "Phase 4: Sticker commands (reply path)" +status: todo +priority: P1 +effort: "5h" +dependencies: [1, 2, 3] +--- + +# Phase 4: Sticker commands (reply path) + +## Overview + +Per-sticker operations on packs the caller owns: `/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. + +All handlers follow the plan's cross-cutting rules. + +## Requirements + +- Functional: add an existing static sticker to one of the caller's packs. +- Functional: remove, re-emoji, and reposition a sticker already in one of those packs. +- 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 +type stickerSource struct { + fileID string // static sticker file_id, or "" when a photo needs uploading + photo *photoRef // Phase 5; nil on the sticker path + emoji []string // from the replied sticker; at most one element +} +func (s *state) resolveSource(msg *models.Message) (stickerSource, error) + +// an EXISTING sticker in one of the caller's packs +type ownedSticker struct { + fileID string + pack Pack +} +func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, 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. `listPacks(ownerID)`, then `matchPack(packs, sticker.SetName)` (Phase 2) — a + case-insensitive comparison against the **stored** `Pack.Name`. +4. No match → the caller does not own it. +5. **Steps 3 and 4 must produce byte-identical reply text.** Distinct messages would let a + user probe which slugs exist under other accounts. Pack *management* refusals stay uniform + even though `/newpack` deliberately discloses slug occupancy (see the plan's Accepted + disclosure section) — the two are different questions. +6. Reject stickers with `IsAnimated` or `IsVideo`. Static-only module; defence in depth. + +Note this uses `matchPack`, not a slug re-derived from the live bot username. Phase 2 +explains why (plan R8): a BotFather rename would otherwise make every user's own packs refuse +as "not yours" while `/packlist` still listed them. + +`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. Slug from args, ownership from `store.Get`, source from `resolveSource`. +Emoji precedence: explicit args → the replied sticker's emoji → `defaultEmoji`. + +`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 "this pack is full (120 stickers)". + +### `/delsticker` + +No arguments. `resolveOwned`, then `DeleteStickerFromSet{Sticker: fileID}`. On success, +decrement `Pack.Count` (floor 0) and commit. + +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 does not depend on either answer. + +The 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 49-sticker pack, which plan C5 +makes unrecoverable without operator help. + +Corrected: **do not probe.** Decrement the count and stop. If the set really is gone, the next +command against it returns `STICKERSET_INVALID`, and the shared handler for that (Phase 3) +deletes the record then — a positive signal, per plan rule 4. This is simpler and strictly +safer than probing. + +### `/editsticker ` + +`resolveOwned`, `parseEmoji` (at least one required — an empty `emoji_list` is invalid), then +`SetStickerEmojiList{Sticker: fileID, EmojiList: emoji}`. + +Deviates from the approved command preview, which included a `` argument — see plan O1. + +### `/ordersticker ` + +`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, ``, ``. +4. Tests per the Todo list. + +## Todo + +- [ ] `resolveSource` sticker branch (photo branch stubbed for Phase 5) +- [ ] `resolveOwned` using `matchPack`, with the 6-step gate and uniform refusal +- [ ] `/addsticker` with emoji precedence and `Count` increment +- [ ] `/delsticker` with `Count` decrement and **no** probe +- [ ] `/editsticker` requiring at least one emoji +- [ ] `/ordersticker` rejecting negatives locally only +- [ ] Register all four with `Parameters` metadata +- [ ] `resolve_test.go`, `sticker_handlers_test.go` + +## Success Criteria + +- [ ] Each command's happy path asserts the expected method in `RecordingBot.Sent()` +- [ ] Missing reply, non-sticker reply, and empty `set_name` each produce a usage error with zero API calls +- [ ] Foreign-bot set and another user's slug produce **byte-identical** reply text, asserted by comparing the two replies to each other +- [ ] A sticker whose `SetName` differs only in case from the stored `Pack.Name` resolves successfully +- [ ] `/ordersticker -1` rejected locally; `/ordersticker 999` reaches the API +- [ ] `/editsticker` with no emoji rejected locally +- [ ] `/delsticker` makes exactly one API call and never deletes the `Pack` record +- [ ] `/addsticker` and `/delsticker` move `Count` by exactly one, floored at 0 +- [ ] Animated/video sticker reply rejected before any API call + +## 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` costs a `listPacks` per invocation** — one `List` plus up to ten `Get`s +against storage, no API calls. Bounded by `maxPacksPerUser` and cheap relative to the +network round trip these commands already make. Signal it matters: storage latency shows up +in command timings. Response: cache the caller's packs for the duration of one handler, which +is a local change since every command resolves at most once. + +**`Count` drift is user-visible but harmless.** Editing a 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..cd23008 --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md @@ -0,0 +1,208 @@ +--- +phase: 5 +title: "Phase 5: Photo pipeline and pack icon" +status: todo +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 + +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/`. **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 one of the caller's packs. + +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 + +- [ ] Add `golang.org/x/image`; verify `go mod tidy` produces no diff +- [ ] `download.go` with 2 MB `LimitReader`, own client timeout, sentinel conversion +- [ ] `classify(err)` returning a coarse label that cannot contain a URL +- [ ] `toStickerPNG` with DecodeConfig guard, CatmullRom scale, PNG size ladder +- [ ] `toThumbnailPNG` at exactly 100×100 with transparent padding +- [ ] Photo/document source selection with mime allowlist and 2 MB pre-check +- [ ] Wire photo branch into `/addsticker` and `/newpack` +- [ ] `/setpackicon` handler and registration +- [ ] `image_test.go` with in-test generated fixtures (no committed binaries) +- [ ] `download_test.go` asserting no token or URL in any returned error + +## Success Criteria + +- [ ] 1024×512 → 512×256; 300×900 → 171×512; 512×512 → 512×512 +- [ ] 1×5000 extreme aspect: short edge clamped to ≥1, no panic, no zero-dimension image +- [ ] Alpha channel preserved through the resize +- [ ] Source above 2 MB rejected with zero HTTP requests made +- [ ] Decoded dimensions above 4096×4096 rejected before pixel allocation +- [ ] Unsupported document mime rejected before download +- [ ] `toThumbnailPNG` output is exactly 100×100 +- [ ] **A forced transport failure against an `httptest` server yields an error whose text contains neither `"bot"` nor the URL** — asserted, not assumed +- [ ] `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..7b6640f --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md @@ -0,0 +1,168 @@ +--- +phase: 6 +title: "Phase 6: Wiring, menu, docs" +status: todo +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. The 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 requires 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 for this module. + +### 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; re-run registry tests to catch later additions. + +### The `/help` rune budget — the real Phase 6 blocker + +`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.** + +Nine commands plus a module header must fit in 884 runes: about **85 runes per command line**, +and that line is `InvocationSentence() + " " + SummarySentence()` +(`internal/modules/command_presentation.go:16-24`), so `Parameters` counts too. Worked +example: `/ordersticker .` is 24 runes, leaving ~61 for its description. + +Write the nine descriptions against that budget *before* wiring, and re-measure. If they do +not fit, decide then whether `/help` needs pagination — that is a separate change, not a +Phase 6 afterthought, 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 | + +### README and docs + +Module table row listing the nine commands, then a `### Sticker packs` section: packs are +created on behalf of the calling user; the bot manages only packs it created; the share link +is permanent even after `/renamepack`; static stickers and photos only; the 10-packs-per-user +and 120-stickers-per-pack caps; 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; +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; 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, so `AGENTS.md` § "Stats Compatibility" does not apply — it +governs renames and deletions. 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 85-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 +- [ ] Update `.env.example` with the explicit list and a why-comment +- [ ] Draft nine descriptions within the ~85-rune budget and measure +- [ ] Add `"sticker": sticker.New` to `factories()` +- [ ] Add nine entries to `expectedParameters` +- [ ] README module-table row + `### Sticker packs` section +- [ ] `docs/sticker-packs.md` +- [ ] Full validation gate +- [ ] Manual smoke sequence against a real token + +## Success Criteria + +- [ ] `gofmt -l .` empty; `go vet ./...` clean; `go test ./...` passes; `golangci-lint run` clean +- [ ] `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: reply to a photo with `/addsticker smoke_pack 😂`; sticker renders undistorted +- [ ] Smoke: `/packlist`, `/renamepack`, `/setpackicon`, `/ordersticker 0` all succeed +- [ ] Smoke: a **second** account is refused on `/addsticker smoke_pack` +- [ ] Smoke: an anonymous group admin is refused with the explanatory message +- [ ] Smoke: `/delpack smoke_pack` → confirm → link 404s; a second press reports already-used +- [ ] 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 `/packlist` 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..86d588f --- /dev/null +++ b/plans/260824-1051-sticker-pack-module/plan.md @@ -0,0 +1,335 @@ +--- +title: "Sticker packs module" +description: "internal/modules/sticker — public, multi-pack-per-user Telegram sticker set management via single-shot reply+args commands using @Stickers command names" +status: pending +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 their own +Telegram sticker packs through the bot. Packs are created on behalf of the calling user +(`user_id`), named `_by_`, and stay bot-manageable because the bot +created them. + +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 a personal sticker pack without leaving the chat | P1 | +| 2 | Ownership is enforced structurally — a user can never mutate another's pack | P1 | +| 3 | No sticker command can stall the bot for other users beyond a bounded deadline | P1 | +| 4 | A partial failure never permanently strands a user's pack | P1 | +| 5 | Command names and semantics recognisable to @Stickers users | P2 | +| 6 | Enabling the module is an explicit operator decision | P2 | + +## Accepted scope + +| Decision | Value | +|---|---| +| Visibility | `VisibilityPublic` — every user manages their own packs | +| Pack model | Named packs, multiple per user, addressed by slug argument | +| 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` | ` ` | yes (sticker/photo) | `GetStickerSet`, `UploadStickerFile`*, `CreateNewStickerSet` | +| `/addsticker` | ` [emoji...]` | yes (sticker/photo) | `UploadStickerFile`*, `AddStickerToSet` | +| `/delsticker` | — | yes (sticker in own pack) | `DeleteStickerFromSet` | +| `/editsticker` | `` | yes (sticker in own pack) | `SetStickerEmojiList` | +| `/ordersticker` | `` | yes (sticker in own pack) | `SetStickerPositionInSet` | +| `/setpackicon` | — | yes (sticker in own pack) | `GetFile`, `SetStickerSetThumbnail` | +| `/renamepack` | ` ` | no | `SetStickerSetTitle` | +| `/delpack` | `` | no (inline confirm) | `DeleteStickerSet` | +| `/packlist` | — | no | **none** — counts come from the store | + +`*` only on the photo path. + +### Deviation from the approved command preview + +The preview showed `/editsticker my_memes 😂🔥` with an explicit pack argument. This plan +drops it: the replied-to sticker carries `set_name`, which is matched against the caller's +stored packs. `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon` all resolve the +pack that way. Open question O1. + +### 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_` +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_` (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) | Pending | +| 2 | [Phase 2: Store, set names, emoji parsing](./phase-02-store-setname-emoji.md) | Pending | +| 3 | [Phase 3: Pack lifecycle commands](./phase-03-pack-lifecycle.md) | Pending | +| 4 | [Phase 4: Sticker commands (reply path)](./phase-04-sticker-commands.md) | Pending | +| 5 | [Phase 5: Photo pipeline and pack icon](./phase-05-photo-pipeline.md) | Pending | +| 6 | [Phase 6: Wiring, menu, docs](./phase-06-wiring-docs.md) | 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): + +- `maxPacksPerUser = 10`, enforced before `CreateNewStickerSet`. +- `handlerTimeout = 10s` on every handler — the primary bound (C2). +- `/packlist` makes **zero** API calls; counts live on the `Pack` record. +- 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) | +| R2 | Partial failure strands a pack | User reports a slug reported taken that `/packlist` 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 | `/packlist` loses an entry the user can still open 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 | + +## 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/_by_` 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_` link +- [ ] `/addsticker mypack 😂` on a photo produces a valid static sticker with a 512px long edge and correct aspect ratio +- [ ] 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 +- [ ] `/packlist` shows slug, title, count, and link for the caller's packs only, making no API calls +- [ ] `/delpack` requires inline confirmation bound to invoker, chat, message, and a TTL +- [ ] 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 + +- **O1** — Should `/editsticker` keep the explicit `` argument from the approved + command preview, or resolve the pack from the replied sticker's `set_name` as planned? +- **O2** — `maxPacksPerUser = 10` no longer drives `/packlist` cost now that counts are + stored, so it is a pure product choice. Keep 10? + +## 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 + + From e56f5c5d7d6eb31ed247e8b6a4da26eff373ebb5 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 25 Aug 2026 11:08:22 +0700 Subject: [PATCH 02/11] docs(plans): revise sticker packs plan to one pack per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command except /newpack drops its argument; the caller's single pack is resolved implicitly. /packlist becomes /mypack, and the store key is the user ID alone. Resolves rather than mitigates the worst red-team finding: /packlist's N+1 plus ten GetStickerSet calls under a 60s per-call ceiling is gone. /mypack is one Get and makes no API calls. resolveOwned collapses to a single Get. Both prior open questions are answered. The slug survives on /newpack alone, where it fixes the permanent share URL. Derived-from-user-id and opaque-id schemes were rejected: the first publishes the owner's Telegram ID forever, the second is unbrandable. /delpack reframed as the only way to change a pack URL, since Telegram exposes no rename-short-name method: - /renamepack's reply names the delete-and-recreate route instead of only stating the link cannot change - /delpack's confirm must state the title, the sticker count being destroyed, the link being surrendered, and that both are permanent - /repack migration rejected for this plan: up to ~121 sequential API calls exceeds handlerTimeout and stalls the bot for all users under C1. Viable only after the Phase 5 offload; recorded as a follow-up Fixes a bug in the write-ahead intent machinery: the different-slug pending branch overwrote unconditionally, permanently orphaning a set created before an interruption. It now probes GetStickerSet first and adopts when the old set exists. Adds R11 — whether a deleted slug can be reclaimed is undocumented and unresolvable without a live bot. Does not block the URL-change path, which needs a different name. Settled by a new Phase 6 smoke step. Phase 1 unchanged. --- .../phase-02-store-setname-emoji.md | 147 ++++++----- .../phase-03-pack-lifecycle.md | 249 ++++++++++-------- .../phase-04-sticker-commands.md | 99 +++---- .../phase-05-photo-pipeline.md | 2 +- .../phase-06-wiring-docs.md | 127 +++++---- plans/260824-1051-sticker-pack-module/plan.md | 148 ++++++++--- 6 files changed, 463 insertions(+), 309 deletions(-) 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 index 05b4ae1..16a0f60 100644 --- 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 @@ -3,7 +3,7 @@ phase: 2 title: "Phase 2: Store, set names, emoji parsing" status: todo priority: P1 -effort: "4h" +effort: "3h" dependencies: [1] --- @@ -12,15 +12,18 @@ dependencies: [1] ## Overview Pure, Telegram-free foundation: the persisted pack record, the mapping between a -user-facing slug and a Telegram set name, sender validation, and emoji-argument parsing. +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 one record per pack, keyed so a lookup by the caller's own user ID is - itself the ownership check. -- Functional: build Telegram set names of the form `_by_`, and match a - sticker's `set_name` against stored packs **without** re-deriving it from the live username. +- 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. @@ -30,32 +33,30 @@ Every function here is unit-testable without a bot. ### Pack record ```go -// Pack is one bot-created sticker set owned by a Telegram user. +// Pack is the single bot-created sticker set owned by a Telegram user. type Pack struct { - Slug string `bson:"slug"` // user-facing id, e.g. "my_memes" - Name string `bson:"name"` // Telegram set name, "my_memes_by_botname" - Title string `bson:"title"` // display title + 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 /packlist API-free + 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 } ``` -`Count` exists so `/packlist` makes zero API calls (plan C2 / R1). It is maintained by -`/addsticker` and `/delsticker` and is explicitly **advisory** — it can drift if a user edits -the pack through @Stickers. Phase 3 documents how it self-heals. +- **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. -`Pending` implements write-ahead intent (plan C5). Phase 3 owns the state machine. + 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. -- Key: `packKey(ownerID int64, slug string)` → `strconv.FormatInt(ownerID,10) + ":" + slug`. - `:` is legal — `internal/storage/keys.go:24-41` forbids only `/`, `.`, `..`, `__…__`, empty, - and >1500 bytes. -- `listPacks(ctx, ownerID)` → `store.List(ctx, ownerID+":")` then `Get` each; sort by slug. - The N+1 is structural: `mongo_doc_store.go:157-165` projects `_id` only. -- Ownership is structural: a command derives the key from the *caller's* ID, so a hit means - the caller owns the pack. -- `maxPacksPerUser = 10` (plan O2), checked before create. +- `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`). @@ -72,39 +73,39 @@ Rejects, in order: nil `msg`/`From`, zero ID, `From.IsBot`, and non-nil `msg.Sen 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 namespace and one quota, and `CreateNewStickerSet` with a bot `user_id` would -fail with an unmapped error. `rg "IsBot" internal/` returns zero hits today — `coin`, `gold`, -and `stock` all check only `From != nil && From.ID != 0`, which is safe for paper-trading -state but not for durable Telegram objects. +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 text should explain the fix ("sticker packs need a personal account — turn off -anonymous posting for this message"), not just deny. +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 40-char cap is for link - readability and title budget; it is **no longer** coupled to callback-payload size, since - Phase 3 puts an opaque id in the callback rather than a slug. -- `buildSetName(slug, botUsername) (string, error)` → `slug + "_by_" + botUsername`, erroring + 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". -- **`matchPack(packs []Pack, setName string) (Pack, bool)`** — case-insensitive comparison of - `setName` against each `pack.Name`. This is the ownership resolver used by Phase 4. +- **`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 the earlier `parseSlug(setName, botUsername)` design. That version - re-derived the slug from the *live* username and discarded the persisted `Pack.Name` - entirely — so renaming the bot in BotFather (a supported operation that leaves existing set - names untouched) would make every user's own packs refuse as "not yours", while `/packlist` - still listed them. It also let a case variant in `SetName` miss the key. Matching the stored - name fixes both (plan R8). + 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** to build names for new packs — never for + 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 - `modules.Build(nil, factories(), …, BuildOptions{})` (`cmd/server/command_menu_test.go:55`). + nil-safe (`internal/modules/module.go:88`) and is nil under `BuildOptions{}` + (`cmd/server/command_menu_test.go:55`). ### Emoji parsing @@ -117,52 +118,54 @@ anonymous posting for this message"), not just deny. - 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 = "⭐"` for -sources carrying none. +server's own message is the literal `too many emoji specified`). `defaultEmoji = "⭐"`. -Note `models.Sticker.Emoji` is a **single string** (`models/sticker.go:23`), so emoji -inherited from a replied sticker yields at most one element. +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: `internal/modules/sticker/pack_test.go`, `setname_test.go`, `sender_test.go`, - `emoji_test.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: `go-telegram/bot@v1.20.0` `models/message.go:86-87` (`SenderChat`), - `models/user.go:12` (`IsBot`), `models/sticker.go:23` (`Emoji` is one string) +- 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, key helpers, `listPacks`, `maxPacksPerUser`. +1. `pack.go` — record, `packKey`, `getPack`. 2. `sender.go` — `senderID` with the bot/anonymous refusals. -3. `setname.go` — slug validation, `buildSetName`, `matchPack`, cached resolver interface. +3. `setname.go` — slug validation, `makeSetName`, `ownsSet`, cached resolver interface. 4. `emoji.go` — cluster scanner and `defaultEmoji`. 5. Tests per the Todo list. ## Todo - [ ] Define `Pack` incl. `Count` and `Pending`; assert no reserved-bson collision -- [ ] `packKey` and `listPacks` with owner-prefix scan -- [ ] `maxPacksPerUser` quota helper +- [ ] `packKey(ownerID)` and `getPack` returning a found flag - [ ] `senderID` rejecting nil/zero/`IsBot`/`SenderChat` with an explanatory message - [ ] `slugRe` validation incl. `__`, trailing `_`, 40-char cap -- [ ] `buildSetName` with 64-char guard and budget-reporting error -- [ ] `matchPack` case-insensitive match against stored `Pack.Name` +- [ ] `makeSetName` with 64-char guard and budget-reporting error +- [ ] `ownsSet` case-insensitive match against stored `Pack.Name` - [ ] `usernameResolver` caching success but never failure, taking the handler's `b` - [ ] `parseEmoji` cluster scanner with the 20-entry cap - [ ] Four test files per the success criteria ## Success Criteria -- [ ] `listPacks` for owner A never returns owner B's packs -- [ ] Quota boundary tested at 9, 10, 11 packs +- [ ] `getPack` for owner A never returns owner B's pack +- [ ] `getPack` on an unknown owner returns `found == false` and a nil error +- [ ] No `List` call exists anywhere in the module - [ ] Slug table rejects leading digit, `__`, trailing `_`, 2 chars, 41 chars -- [ ] `buildSetName` errors when `len(slug)+len("_by_"+username) > 64` -- [ ] `matchPack` matches `MyPack_by_Bot` against a stored `mypack_by_bot` -- [ ] `matchPack` returns false for a set name belonging to another bot -- [ ] A simulated bot username change does **not** break `matchPack` for existing packs +- [ ] `makeSetName` errors when `len(slug)+len("_by_"+username) > 64` +- [ ] `ownsSet` matches `MyPack_by_Bot` against a stored `mypack_by_bot` +- [ ] `ownsSet` returns false for a set name belonging to another bot +- [ ] A simulated bot username change does **not** break `ownsSet` for an existing pack - [ ] `senderID` rejects `IsBot: true` and a non-nil `SenderChat`, each with zero store access - [ ] `parseEmoji` handles joined input, ZWJ family, flag, keycap, skin tone; rejects plain text; errors above 20 - [ ] `gofmt -l internal/modules/sticker` empty; `go test`/`go vet` clean @@ -174,12 +177,12 @@ dependency for this is disproportionate. Signal: a user reports an emoji split o Response: extend the table-driven test with the failing sequence. If failures accumulate across many scripts, take a segmentation dependency. -**`Count` can drift.** A user editing their pack through @Stickers changes the real count -without the bot seeing it. Accepted: the field is advisory and only feeds a display column. -Phase 3 refreshes it opportunistically whenever a command already holds a `GetStickerSet` -response, so it self-heals without any command paying for a lookup it did not need. +**`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. -**The bot username is still a single point of failure for `/newpack`.** `matchPack` protects -existing packs from a rename (R8), but creation still builds `_by_`. -After a rename, old packs keep working and new ones use the new suffix — correct behaviour, -but worth stating so nobody "fixes" it later by rewriting stored names. +**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 index 07be8e1..75ad607 100644 --- a/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md +++ b/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md @@ -3,7 +3,7 @@ phase: 3 title: "Phase 3: Pack lifecycle commands" status: todo priority: P1 -effort: "8h" +effort: "7h" dependencies: [1, 2] --- @@ -11,148 +11,178 @@ dependencies: [1, 2] ## Overview -`/newpack`, `/packlist`, `/renamepack`, `/delpack` (+ confirm callback). These own pack -creation and destruction, and they carry the plan's two hardest correctness problems: -surviving partial failure, and making an irreversible delete safe to confirm. +`/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 a sticker set on behalf of the caller and persist its record such that - no interruption can permanently strand the slug. -- Functional: list, rename, and delete the caller's own packs, never another user's. +- 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: `/packlist` 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. +- 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), `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` can ship `/ff`. +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. ### `/newpack ` — write-ahead intent -The earlier draft did "create on Telegram, then write the store", and accepted that an -interruption strands 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 actually 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. +`` 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. Quota check via `listPacks`. -4. `buildSetName(slug, username)`. -5. **`PutVersioned(ctx, key, 0, Pack{Pending: true, …})`** — the create-only primitive +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`). `ErrConflict` means this owner already has a record for - this slug: if it is `Pending`, resume at step 6 (this is the retry path); if confirmed, - reply "you already have that pack". + `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. -6. `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 8. - If we did *not* hold a pending record we would not have reached here — step 5 would - have created one — so adoption is only ever of our own intent. - - **`isStickerSetMissing`** → the slug is free; proceed to step 7. +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). -7. `CreateNewStickerSet{UserID, Name, Title, Stickers: []InputSticker{{Sticker: fileID, Format: "static", EmojiList: emoji}}}`. +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 slug. -8. Commit: `Put(context.WithoutCancel(ctx), key, Pack{Pending: false, Count: 1, …})`. + 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 now completes the operation -instead of reporting it taken. That is the plan's "interrupted `/newpack` can be completed by -re-running" success criterion. +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. -A pending record does consume a quota slot until resolved. Acceptable at -`maxPacksPerUser = 10`; a stale-pending sweep is a follow-up, not this phase. +### `/mypack` — zero API calls -### `/packlist` — 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. -`listPacks(senderID)` rendered with `chathelper.MonospaceTable` (slug, title, `Count`, link). -Pending records render with a "(incomplete — re-run /newpack)" marker rather than being -hidden, so a user can see and fix a stranded attempt. +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. -The earlier design 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 is a ~10-minute bot-wide freeze from one argument-free public command — -strictly worse than the photo pipeline the plan had been treating as its main risk. Counts -now come from `Pack.Count` (Phase 2). +### `/renamepack <title...>` -### `/renamepack <pack> <title...>` +`getPack(senderID)`; absent → "you don't have a pack yet". `SetStickerSetTitle{Name, Title}`, +then commit the new `Title` under `WithoutCancel`. -Ownership via `store.Get(packKey(sender, slug))`. `SetStickerSetTitle{Name, Title}`, then -commit the new `Title` under `WithoutCancel`. The reply must state the share link is -unchanged — the Telegram short name is permanent. +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. -**Reverse gap (was undocumented):** if the API succeeds and the commit fails, `/packlist` -shows a title Telegram no longer has. Cosmetic only, self-heals on the next successful -rename. Documented rather than mitigated. +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). -### `/delpack <pack>` — bound confirmation +**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. -The earlier design put the slug in the callback data and re-checked ownership from +### `/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 expires, is not bound to a chat or message, and lives in scrollback -forever. Alice cancels, recreates `memes` months later with 100 stickers, taps the stale -button, and it is destroyed with no fresh intent. Three reviewers flagged it independently, -and the `stock` module the draft cited as its model already solves this properly. +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. -Follow `stock` fully, not half of it: +With one pack per user the payload needs no slug at all — but it still needs everything else: -1. `/delpack` validates ownership, then writes a pending action: +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). -2. Callback data is `sticker_pack:d:<opaque id>` — an id, not a slug. Well under the 64-byte - cap (C3), and it decouples the payload from slug length entirely. + `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 → answer "this confirmation expired or was already - used" (`stock/dividend_callback.go:66-80` is the model); + - 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`) and is 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 the excuse to skip the guard; + (`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`). + - `AnswerCallbackQuery` and clear the button via `EditMessageReplyMarkup` with empty markup, + using `action.ChatID`/`action.MessageID` (`dividend_callback.go:26-33`). -**Reverse gap (was undocumented):** if `DeleteStickerSet` succeeds and `store.Delete` fails, -a phantom record survives — consuming a quota slot, rendering in `/packlist`, and erroring on -every operation. This is worse than an orphan set, because the user cannot see why they are -at their limit. Mitigation: `/packlist` and every command that receives -`isStickerSetMissing` from the API deletes the offending record on the spot, so a phantom -self-heals on first contact. +**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>`. +`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` | that pack no longer exists (and delete the record) | +| `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 | @@ -171,7 +201,7 @@ rewritten into prose by the Bot API server; everything else arrives as `Bad Requ 1. `state.go`, `sticker.go` wiring four commands + the `sticker_pack:` callback. 2. `errors.go` with `replyAPIError` and `isStickerSetMissing`. -3. `/packlist` first — no mutations, no API calls, easiest to verify. +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. @@ -182,43 +212,52 @@ rewritten into prose by the Bot API server; everything else arrives as `Bad Requ - [ ] `state.go` with store, pending view, resolver, locks, nowFn, `handlerTimeout` - [ ] `sticker.go` factory registering 4 commands + callback prefix - [ ] `errors.go`: `replyAPIError` code table + `isStickerSetMissing` -- [ ] `/packlist` reading `Count`, marking pending records, zero API calls -- [ ] `/newpack` steps 1-8 incl. `PutVersioned` intent and adoption -- [ ] `/renamepack` with permanent-link note and `WithoutCancel` commit +- [ ] `/mypack` reading `Count`, marking a pending record, zero API calls +- [ ] `/newpack` steps 1-7 incl. `PutVersioned` intent, three `ErrConflict` branches, adoption +- [ ] Different-slug pending branch probes `GetStickerSet(oldName)` before overwriting +- [ ] `/renamepack` reply: new title, unchanged link, and the /delpack + /newpack route +- [ ] `/delpack` confirm prompt: title, sticker count, link, permanence - [ ] `pending_delete.go` with TTL, chat/message binding, opaque id -- [ ] `/delpack` emitting the bound confirm keyboard +- [ ] `/delpack` naming the pack in its confirm prompt - [ ] `delpack_callback.go` with expiry, binding, nil-message guard, single-use - [ ] Record self-heal on `isStickerSetMissing` across commands - [ ] Tests per the success criteria ## Success Criteria -- [ ] `/packlist` records **zero** entries in `RecordingBot.Sent()` -- [ ] Interrupted `/newpack` (pending record present, set exists) completes on re-run and does not report the slug taken +- [ ] `/mypack` records **zero** entries in `RecordingBot.Sent()` +- [ ] A second `/newpack` with a confirmed pack present is refused, names the existing slug, and makes zero API calls +- [ ] Interrupted `/newpack` (pending record, same slug, set exists) completes on re-run and does not report the slug taken +- [ ] Interrupted `/newpack` with a *different* slug where the old set **exists** adopts the old set and refuses the new slug, leaving nothing orphaned +- [ ] Interrupted `/newpack` with a *different* slug where the old set is **missing** replaces the pending record and proceeds - [ ] `/newpack` where `GetStickerSet` fails with a non-missing error aborts, deletes the pending record, and never calls `CreateNewStickerSet` -- [ ] `/newpack` uses `PutVersioned(…, 0, …)`; a second create attempt surfaces `ErrConflict` rather than overwriting -- [ ] Foreign-pack access replies with the ownership error and records zero API calls -- [ ] Quota exceeded at 11 packs blocks before any API call +- [ ] `/newpack` uses `PutVersioned(…, 0, …)`; the create path never calls `Put` for a new record +- [ ] `/renamepack` with no pack replies "you don't have a pack yet" and makes zero API calls - [ ] `/delpack` confirm after `ExpiresAt` is refused as expired, with no `DeleteStickerSet` - [ ] `/delpack` confirm from a different `From.ID` is refused, with no `DeleteStickerSet` - [ ] `/delpack` confirm with a nil `CallbackQuery.Message.Message` is handled without panic - [ ] Pressing the same confirm twice deletes once; the second press reports already-used - [ ] Callback data is asserted ≤ 64 bytes -- [ ] A command receiving `STICKERSET_INVALID` deletes the stale record +- [ ] A command receiving `STICKERSET_INVALID` deletes the stale record, unblocking `/newpack` - [ ] Title of 65 chars rejected locally, before any API call +- [ ] `/delpack` confirm text contains the pack title, the sticker count, and the share link +- [ ] `/renamepack` reply names the `/delpack` + `/newpack` route ## Risk Assessment -**The write-ahead state machine is the most intricate logic in the plan.** Its correctness -rests on one property: a `Pending` record for `(owner, slug)` means *this owner* asked for -*this 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 6 becomes "slug taken") and fall back to the documented -orphan gap — a one-line change, deliberately. +**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 pending record consumes quota until resolved.** A user who abandons an interrupted -`/newpack` sees 9 usable slots. Visible in `/packlist` with its marker, and re-running the -command clears it. A sweep for pending records older than an hour is a follow-up. +**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 index 822c35d..1cb3f3e 100644 --- a/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md +++ b/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md @@ -3,7 +3,7 @@ phase: 4 title: "Phase 4: Sticker commands (reply path)" status: todo priority: P1 -effort: "5h" +effort: "4h" dependencies: [1, 2, 3] --- @@ -11,16 +11,18 @@ dependencies: [1, 2, 3] ## Overview -Per-sticker operations on packs the caller owns: `/addsticker` (existing-sticker source), +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 one of the caller's packs. -- Functional: remove, re-emoji, and reposition a sticker already in one of those packs. +- 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. @@ -38,7 +40,7 @@ type stickerSource struct { } func (s *state) resolveSource(msg *models.Message) (stickerSource, error) -// an EXISTING sticker in one of the caller's packs +// an EXISTING sticker in the caller's pack type ownedSticker struct { fileID string pack Pack @@ -46,37 +48,41 @@ type ownedSticker struct { func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, error) ``` -`resolveOwned` is the single ownership gate for `/delsticker`, `/editsticker`, -`/ordersticker`, and (Phase 5) `/setpackicon`: +`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. `listPacks(ownerID)`, then `matchPack(packs, sticker.SetName)` (Phase 2) — a - case-insensitive comparison against the **stored** `Pack.Name`. -4. No match → the caller does not own it. -5. **Steps 3 and 4 must produce byte-identical reply text.** Distinct messages would let a - user probe which slugs exist under other accounts. Pack *management* refusals stay uniform - even though `/newpack` deliberately discloses slug occupancy (see the plan's Accepted - disclosure section) — the two are different questions. +3. `getPack(ownerID)` — **one `Get`**. Absent → the caller has no pack. +4. `ownsSet(pack, sticker.SetName)` (Phase 2) — case-insensitive against the **stored** + `Pack.Name`. False → the sticker is not from the caller's pack. +5. **Steps 3 and 4 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. 6. Reject stickers with `IsAnimated` or `IsVideo`. Static-only module; defence in depth. -Note this uses `matchPack`, not a slug re-derived from the live bot username. Phase 2 -explains why (plan R8): a BotFather rename would otherwise make every user's own packs refuse -as "not yours" while `/packlist` still listed them. +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 <pack> [emoji...]` +### `/addsticker [emoji...]` -Reply required. Slug from args, ownership from `store.Get`, source from `resolveSource`. -Emoji precedence: explicit args → the replied sticker's emoji → `defaultEmoji`. +Reply required. Pack from `getPack`, source from `resolveSource`. Emoji precedence: explicit +args → the replied sticker's emoji → `defaultEmoji`. + +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 "this pack is full (120 stickers)". +`WithoutCancel`. `STICKERS_TOO_MUCH` maps to "your pack is full (120 stickers)". ### `/delsticker` @@ -84,32 +90,30 @@ No arguments. `resolveOwned`, then `DeleteStickerFromSet{Sticker: fileID}`. On s decrement `Pack.Count` (floor 0) and commit. 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 does not depend on either answer. +docs or the open-source Bot API server, so the plan depends on neither answer. -The 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 49-sticker pack, which plan C5 -makes unrecoverable without operator help. +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 against it returns `STICKERSET_INVALID`, and the shared handler for that (Phase 3) -deletes the record then — a positive signal, per plan rule 4. This is simpler and strictly -safer than probing. +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. ### `/editsticker <emoji...>` `resolveOwned`, `parseEmoji` (at least one required — an empty `emoji_list` is invalid), then `SetStickerEmojiList{Sticker: fileID, EmojiList: emoji}`. -Deviates from the approved command preview, which included a `<pack>` argument — see plan O1. - ### `/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`. +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}`. @@ -127,13 +131,13 @@ set size and a local copy would go stale. Its error goes through `replyAPIError` 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`: - `<pack> [emoji...]`, none, `<emoji...>`, `<position>`. + `[emoji...]`, none, `<emoji...>`, `<position>`. 4. Tests per the Todo list. ## Todo - [ ] `resolveSource` sticker branch (photo branch stubbed for Phase 5) -- [ ] `resolveOwned` using `matchPack`, with the 6-step gate and uniform refusal +- [ ] `resolveOwned` using `getPack` + `ownsSet`, with the 6-step gate and uniform refusal - [ ] `/addsticker` with emoji precedence and `Count` increment - [ ] `/delsticker` with `Count` decrement and **no** probe - [ ] `/editsticker` requiring at least one emoji @@ -145,8 +149,9 @@ set size and a local copy would go stale. Its error goes through `replyAPIError` - [ ] Each command's happy path asserts the expected method in `RecordingBot.Sent()` - [ ] Missing reply, non-sticker reply, and empty `set_name` each produce a usage error with zero API calls -- [ ] Foreign-bot set and another user's slug produce **byte-identical** reply text, asserted by comparing the two replies to each other +- [ ] "No pack yet" and "sticker from another bot's set" produce **byte-identical** reply text, asserted by comparing the two replies to each other - [ ] A sticker whose `SetName` differs only in case from the stored `Pack.Name` resolves successfully +- [ ] `/addsticker` with a non-emoji argument is rejected by `parseEmoji`, not silently reinterpreted - [ ] `/ordersticker -1` rejected locally; `/ordersticker 999` reaches the API - [ ] `/editsticker` with no emoji rejected locally - [ ] `/delsticker` makes exactly one API call and never deletes the `Pack` record @@ -155,17 +160,15 @@ set size and a local copy would go stale. Its error goes through `replyAPIError` ## 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. +**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` costs a `listPacks` per invocation** — one `List` plus up to ten `Get`s -against storage, no API calls. Bounded by `maxPacksPerUser` and cheap relative to the -network round trip these commands already make. Signal it matters: storage latency shows up -in command timings. Response: cache the caller's packs for the duration of one handler, which -is a local change since every command resolves at most once. +**`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 a pack through @Stickers desyncs it. +**`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 index cd23008..b3ca356 100644 --- a/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md +++ b/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md @@ -127,7 +127,7 @@ matters. Do not restructure into upload-now-use-later. ### `/setpackicon` -No arguments; reply to a sticker in one of the caller's packs. +No arguments; reply to a sticker in the caller's pack. 1. `resolveOwned` (Phase 4). 2. `GetFile` + download that sticker's image, then `toThumbnailPNG`. 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 index 7b6640f..4ba2815 100644 --- a/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md +++ b/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md @@ -11,9 +11,9 @@ dependencies: [1, 2, 3, 4, 5] ## 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. +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 @@ -31,7 +31,7 @@ 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. The earlier +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. @@ -44,36 +44,48 @@ Ordered fix, per the user's decision: 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 requires enumerating eleven module names into an empty variable under -pressure. +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 for this module. +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. +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; re-run registry tests to catch later additions. +(`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 — the real Phase 6 blocker +### 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.** -Nine commands plus a module header must fit in 884 runes: about **85 runes per command line**, -and that line is `InvocationSentence() + " " + SummarySentence()` -(`internal/modules/command_presentation.go:16-24`), so `Parameters` counts too. Worked -example: `/ordersticker <position>.` is 24 runes, leaving ~61 for its description. +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: -Write the nine descriptions against that budget *before* wiring, and re-measure. If they do -not fit, decide then whether `/help` needs pagination — that is a separate change, not a -Phase 6 afterthought, and it must not be "solved" by trimming other modules' descriptions. +| 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 @@ -84,28 +96,35 @@ Phase 6 afterthought, and it must not be "solved" by trimming other modules' des | `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` → ``. + ### README and docs -Module table row listing the nine commands, then a `### Sticker packs` section: packs are -created on behalf of the calling user; the bot manages only packs it created; the share link -is permanent even after `/renamepack`; static stickers and photos only; the 10-packs-per-user -and 120-stickers-per-pack caps; anonymous group admins are not supported and why. +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. +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; -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; conversational flow — no message hook at `internal/modules/dispatcher.go:66`); the -accepted slug-occupancy disclosure; and the `Count` drift note. +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, so `AGENTS.md` § "Stats Compatibility" does not apply — it -governs renames and deletions. New commands accrue stats through the dispatcher hook -(`dispatcher.go:80-84`). +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 @@ -118,7 +137,7 @@ governs renames and deletions. New commands accrue stats through the dispatcher ## Implementation Steps 1. Set `MODULES` explicitly in the deployed environment; verify an unchanged command set. -2. Draft the nine descriptions against the 85-rune budget; measure `RenderHelp` locally. +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`. @@ -128,7 +147,8 @@ governs renames and deletions. New commands accrue stats through the dispatcher - [ ] Set `MODULES` explicitly in the deployed environment and verify - [ ] Update `.env.example` with the explicit list and a why-comment -- [ ] Draft nine descriptions within the ~85-rune budget and measure +- [ ] Confirm `mypack` is free in the registry alongside the other eight +- [ ] Draft nine descriptions within the measured budget and re-measure `RenderHelp` - [ ] Add `"sticker": sticker.New` to `factories()` - [ ] Add nine entries to `expectedParameters` - [ ] README module-table row + `### Sticker packs` section @@ -143,26 +163,31 @@ governs renames and deletions. New commands accrue stats through the dispatcher - [ ] 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: reply to a photo with `/addsticker smoke_pack 😂`; sticker renders undistorted -- [ ] Smoke: `/packlist`, `/renamepack`, `/setpackicon`, `/ordersticker 0` all succeed -- [ ] Smoke: a **second** account is refused on `/addsticker smoke_pack` +- [ ] 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 smoke_pack` → confirm → link 404s; a second press reports already-used +- [ ] 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. +**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. +**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 `/packlist` before anyone enabled it. Response: set `MODULES` immediately; the module -is otherwise harmless until someone runs a command. +**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 index 86d588f..ccef672 100644 --- a/plans/260824-1051-sticker-pack-module/plan.md +++ b/plans/260824-1051-sticker-pack-module/plan.md @@ -1,6 +1,6 @@ --- title: "Sticker packs module" -description: "internal/modules/sticker — public, multi-pack-per-user Telegram sticker set management via single-shot reply+args commands using @Stickers command names" +description: "internal/modules/sticker — public, one-pack-per-user Telegram sticker set management via single-shot reply commands using @Stickers command names" status: pending priority: P2 effort: "" @@ -15,10 +15,13 @@ blocks: [] ## Overview -New module `internal/modules/sticker` letting **any** user create and manage their own -Telegram sticker packs through the bot. Packs are created on behalf of the calling user -(`user_id`), named `<slug>_by_<bot_username>`, and stay bot-manageable because the bot -created them. +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 @@ -31,19 +34,20 @@ prerequisites, not incidental work. | # | Goal | Priority | |---|------|----------| -| 1 | Any user can create and fill a personal sticker pack without leaving the chat | P1 | -| 2 | Ownership is enforced structurally — a user can never mutate another's pack | P1 | -| 3 | No sticker command can stall the bot for other users beyond a bounded deadline | P1 | -| 4 | A partial failure never permanently strands a user's pack | P1 | -| 5 | Command names and semantics recognisable to @Stickers users | P2 | -| 6 | Enabling the module is an explicit operator decision | P2 | +| 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 | Named packs, multiple per user, addressed by slug argument | +| 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 | @@ -55,23 +59,27 @@ All `VisibilityPublic`. `Parameters` follows `docs/command-parameter-conventions | Command | Parameters | Reply required | API calls | |---|---|---|---| | `/newpack` | `<pack> <title...>` | yes (sticker/photo) | `GetStickerSet`, `UploadStickerFile`*, `CreateNewStickerSet` | -| `/addsticker` | `<pack> [emoji...]` | yes (sticker/photo) | `UploadStickerFile`*, `AddStickerToSet` | +| `/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` | `<pack> <title...>` | no | `SetStickerSetTitle` | -| `/delpack` | `<pack>` | no (inline confirm) | `DeleteStickerSet` | -| `/packlist` | — | no | **none** — counts come from the store | +| `/renamepack` | `<title...>` | no | `SetStickerSetTitle` | +| `/delpack` | — | no (inline confirm) | `DeleteStickerSet` | +| `/mypack` | — | no | **none** — count comes from the store | `*` only on the photo path. -### Deviation from the approved command preview +### Slug is a name, not an address -The preview showed `/editsticker my_memes 😂🔥` with an explicit pack argument. This plan -drops it: the replied-to sticker carries `set_name`, which is matched against the caller's -stored packs. `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon` all resolve the -pack that way. Open question O1. +`<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 @@ -227,9 +235,11 @@ repo's first *direct* `golang.org/x/*` requirement; all current ones are indirec Public module creating durable Telegram-side objects on a single-threaded dispatcher (C1): -- `maxPacksPerUser = 10`, enforced before `CreateNewStickerSet`. +- 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). -- `/packlist` makes **zero** API calls; counts live on the `Pack` record. +- `/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, @@ -241,12 +251,14 @@ Public module creating durable Telegram-side objects on a single-threaded dispat | # | 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) | -| R2 | Partial failure strands a pack | User reports a slug reported taken that `/packlist` does not show | Write-ahead intent + `WithoutCancel` commits (Phase 3). Reverse gaps for `/delpack` and `/renamepack` documented in Phase 3 | +| 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 | `/packlist` loses an entry the user can still open via link | Rule 4 — destructive store deletes require positive `STICKERSET_INVALID` | +| 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 | @@ -262,11 +274,14 @@ 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 mypack 😂` on a photo produces a valid static sticker with a 512px long edge and correct aspect ratio +- [ ] `/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 -- [ ] `/packlist` shows slug, title, count, and link for the caller's packs only, making no API calls -- [ ] `/delpack` requires inline confirmation bound to invoker, chat, message, and a TTL +- [ ] `/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 @@ -277,10 +292,79 @@ otherwise disclose which of the caller's own guesses correspond to real packs. ## Open Questions -- **O1** — Should `/editsticker` keep the explicit `<pack>` argument from the approved - command preview, or resolve the pack from the replied sticker's `set_name` as planned? -- **O2** — `maxPacksPerUser = 10` no longer drives `/packlist` cost now that counts are - stored, so it is a pure product choice. Keep 10? +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 — 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. + + +#### 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 From 24f0cde1b3a9b35f624da9ad4fb2b99b2d1f2a16 Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 15:54:01 +0700 Subject: [PATCH 03/11] feat(modules): recover panics in command and callback dispatch The bot runs with WithNotAsyncHandlers and a single worker, so handlers execute inline on the polling goroutine. A panic in any handler therefore killed the process and took every user's bot down with it. Wrap the command closure, the callback closure and the detached command hook in a recover barrier. The callback path also answers the pending query so the client stops spinning rather than waiting out its timeout. The barrier is a backstop, not a licence to skip nil checks: handlers still guard their own inputs. --- internal/modules/dispatcher.go | 44 ++++- internal/modules/dispatcher_panic_test.go | 203 ++++++++++++++++++++++ 2 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 internal/modules/dispatcher_panic_test.go 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") +} From 12d7b4f5ee495dcf14a611795701d4943eff31ce Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 15:54:12 +0700 Subject: [PATCH 04/11] test(testutil): stub struct results and coded failures in the recording bot Three gaps made parts of the Telegram API untestable: - Methods that decode into a struct (getStickerSet, getFile, getMe, uploadStickerFile) only ever saw `{"ok":true,"result":true}`, so they could return nothing but unmarshal errors. StubMethod supplies a real result payload. - The library classifies errors from the error_code in the response body, not the HTTP status, so a codeless failure never took a sentinel shape. FailMethodCode emits the code, letting handlers that branch on errors.Is be tested at all. - Parameterless calls send no body, and the unconditional form parse rejected them before any stub applied. The parse tolerance is scoped to an empty body rather than to any parse failure: multipart reports "no parts" for both an absent body and a corrupt one, and answering a corrupt request 200 with an empty form would quietly satisfy tests elsewhere that assert a field is absent. --- internal/testutil/recording_bot.go | 106 ++++++++++++++++++++++-- internal/testutil/recording_bot_test.go | 105 +++++++++++++++++++++++ 2 files changed, 202 insertions(+), 9 deletions(-) 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) + } +} From 6baa2bfe72d8de4c3d6b85c60f0a43ede07cc60c Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 15:54:12 +0700 Subject: [PATCH 05/11] style(wordle): gofmt lookup test table --- internal/modules/wordle/lookup_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 { From 4e805f0a7fb3536543c499d0b5bc9a4c94c61fa6 Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 15:54:28 +0700 Subject: [PATCH 06/11] feat(sticker): add sticker pack module Nine commands mirroring the names @Stickers uses: /newpack, /mypack, /addsticker, /delsticker, /editsticker, /ordersticker, /setpackicon, /renamepack and /delpack, plus a confirm callback for the destructive one. Sources are replied stickers, photos or image documents; photos are downloaded, resampled to 512px and re-uploaded. One pack per user, keyed by owner id. Creating a pack is the only operation here that makes a durable, publicly linkable object on a user's behalf, so it is built around proving ownership rather than assuming it: - A name is claimed globally and create-only before Telegram is called. A pending record alone proves only that a caller *asked* for a name, which is exactly what someone naming a victim's public slug also does. - Adopting an existing set additionally requires that the claim predates this invocation. The claim lives in our store and the pack lives at Telegram, so a wiped store would otherwise make every pack adoptable. - Names are released only on positive evidence that no pack stands behind them, never on a generic failure, so a transient error cannot hand a live name to the next caller. - Ownership refusals are byte-identical across failure modes, so they cannot be used to probe which sets exist. Error classification is positive-only throughout: "the set is gone" and "nothing was created" are each proven from a specific Telegram response, never inferred from an error. Post-action commits run on a context detached from the request so a shutdown mid-handler cannot lose the record of something Telegram already did. Enabled explicitly via MODULES rather than by default. --- .env.example | 7 +- README.md | 1 + cmd/server/command_menu_test.go | 27 + cmd/server/main.go | 2 + docs/command-parameter-conventions.md | 2 + docs/sticker-packs.md | 145 ++++ go.mod | 3 +- go.sum | 6 +- internal/modules/sticker/delpack_callback.go | 223 ++++++ .../modules/sticker/delpack_callback_test.go | 389 ++++++++++ internal/modules/sticker/download.go | 106 +++ internal/modules/sticker/download_test.go | 157 ++++ internal/modules/sticker/emoji.go | 209 ++++++ internal/modules/sticker/emoji_test.go | 154 ++++ internal/modules/sticker/errors.go | 123 ++++ internal/modules/sticker/handlers_test.go | 440 +++++++++++ internal/modules/sticker/image.go | 162 ++++ internal/modules/sticker/image_test.go | 156 ++++ internal/modules/sticker/pack.go | 100 +++ internal/modules/sticker/pack_handlers.go | 619 ++++++++++++++++ .../modules/sticker/pack_handlers_test.go | 691 ++++++++++++++++++ internal/modules/sticker/pack_test.go | 71 ++ internal/modules/sticker/pending_delete.go | 103 +++ internal/modules/sticker/photo.go | 86 +++ internal/modules/sticker/photo_test.go | 86 +++ internal/modules/sticker/resolve.go | 120 +++ internal/modules/sticker/resolve_test.go | 190 +++++ internal/modules/sticker/sender.go | 39 + internal/modules/sticker/sender_test.go | 63 ++ internal/modules/sticker/setname.go | 119 +++ internal/modules/sticker/setname_test.go | 97 +++ internal/modules/sticker/setpackicon.go | 56 ++ internal/modules/sticker/state.go | 118 +++ internal/modules/sticker/sticker.go | 89 +++ internal/modules/sticker/sticker_handlers.go | 224 ++++++ 35 files changed, 5178 insertions(+), 5 deletions(-) create mode 100644 docs/sticker-packs.md create mode 100644 internal/modules/sticker/delpack_callback.go create mode 100644 internal/modules/sticker/delpack_callback_test.go create mode 100644 internal/modules/sticker/download.go create mode 100644 internal/modules/sticker/download_test.go create mode 100644 internal/modules/sticker/emoji.go create mode 100644 internal/modules/sticker/emoji_test.go create mode 100644 internal/modules/sticker/errors.go create mode 100644 internal/modules/sticker/handlers_test.go create mode 100644 internal/modules/sticker/image.go create mode 100644 internal/modules/sticker/image_test.go create mode 100644 internal/modules/sticker/pack.go create mode 100644 internal/modules/sticker/pack_handlers.go create mode 100644 internal/modules/sticker/pack_handlers_test.go create mode 100644 internal/modules/sticker/pack_test.go create mode 100644 internal/modules/sticker/pending_delete.go create mode 100644 internal/modules/sticker/photo.go create mode 100644 internal/modules/sticker/photo_test.go create mode 100644 internal/modules/sticker/resolve.go create mode 100644 internal/modules/sticker/resolve_test.go create mode 100644 internal/modules/sticker/sender.go create mode 100644 internal/modules/sticker/sender_test.go create mode 100644 internal/modules/sticker/setname.go create mode 100644 internal/modules/sticker/setname_test.go create mode 100644 internal/modules/sticker/setpackicon.go create mode 100644 internal/modules/sticker/state.go create mode 100644 internal/modules/sticker/sticker.go create mode 100644 internal/modules/sticker/sticker_handlers.go 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 <username>`, `/stats cmd <command_name>` | | `monkeyd` | `/monkeyd_crawl <url> [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags <url>` 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": "<url> [font_size]", "monkeyd_tags": "<url>", "random": "<option,...>", + "newpack": "<pack> <title...>", + "mypack": "", + "addsticker": "[emoji...]", + "delsticker": "", + "editsticker": "<emoji...>", + "ordersticker": "<position>", + "setpackicon": "", + "renamepack": "<title...>", + "delpack": "", "stats": "[users | user <username> | cmd <command_name>]", "stock_events": "<ticker> [days]", "stock_info": "<ticker>", @@ -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 | `<name>` | `<ticker>` | | Required comma-separated values | `<name,...>` | `<option,...>` | +| Required remaining text | `<name...>` | `<title...>` | | Optional value | `[name]` | `[date]` | | Optional remaining text | `[name...]` | `[target...]` | | Alternatives in an optional group | `[literal | literal <name>]` | `[users | user <username>]` | @@ -40,6 +41,7 @@ language or extra punctuation without a user-facing need. ```text /stock_buy <quantity> <ticker> +/renamepack <title...> /lol [date] /trongtruonghop [target...] /stats [users | user <username> | cmd <command_name>] diff --git a/docs/sticker-packs.md b/docs/sticker-packs.md new file mode 100644 index 0000000..5482d47 --- /dev/null +++ b/docs/sticker-packs.md @@ -0,0 +1,145 @@ +# 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` | `<pack> <title...>` | 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` | `<emoji...>` | a sticker in your pack | Replaces that sticker's emoji | +| `/ordersticker` | `<position>` | a sticker in your pack | Moves it; positions start at 0 | +| `/setpackicon` | — | a sticker in your pack | Uses it as the pack icon | +| `/renamepack` | `<title...>` | — | 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_<botusername>`. + +**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/<name>_by_<bot>` 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 the point: the claim is what proves, on a later re-run, that an +existing set under that name is yours to finish rather than someone else's to +take. + +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. + +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. + +**An interrupted `/newpack` can be finished.** The bot records your intent +before calling Telegram, so if a deploy or crash lands mid-creation, re-running +the same `/newpack` command completes it instead of reporting the name taken. +`/mypack` marks an unfinished attempt so it is visible rather than mysterious. + +This depends on the bot still holding your claim to the name. If its storage has +been wiped since — which is what a restart does when no database is configured — +the claim is gone while the pack at Telegram is not, and `/newpack` reports the +name as taken rather than adopting a set it can no longer prove is yours. +Recovering a pack in that state needs operator help. Run this module against a +real database, not the in-memory backend. + +**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/sticker/delpack_callback.go b/internal/modules/sticker/delpack_callback.go new file mode 100644 index 0000000..233aed9 --- /dev/null +++ b/internal/modules/sticker/delpack_callback.go @@ -0,0 +1,223 @@ +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) + } + + 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.") + } + + 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)() + + // 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..d4249b2 --- /dev/null +++ b/internal/modules/sticker/delpack_callback_test.go @@ -0,0 +1,389 @@ +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. +func TestDelPackCallback_RejectsOtherUser(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) + // The old set no longer exists: this is the deleted-then-recreated flow. + 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) + } + + 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") + } +} 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<TOKEN>/<path>", 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<TOKEN>/<path>", 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 "😀<ZWJ>🇻🇳" 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. "😀<ZWJ>" 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: <CODE>", 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..51c66b9 --- /dev/null +++ b/internal/modules/sticker/pack.go @@ -0,0 +1,100 @@ +// 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 "<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. +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, "<slug>_by_<botname>" + 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. Adopting on that evidence +// alone let any user take over any pack whose public link they could guess. +// +// The reservation is the missing half. It is claimed with a create-only write +// before Telegram is touched, so the first claimant of a name is the only user +// who can ever adopt a set under it. +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..65191bd --- /dev/null +++ b/internal/modules/sticker/pack_handlers.go @@ -0,0 +1,619 @@ +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 <pack> <title...>\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 <pack> <title...> 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; treating it +// as proof of ownership was a pack-takeover hole. The reservation is what makes +// "this set is mine to adopt" a fact rather than an assumption. +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.createOrAdopt(ctx, b, msg, claimed, source, created) +} + +// 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. That is not enough to make adoption safe: a user with no +// pack who names someone else's slug produces exactly the same evidence as a +// user resuming their own interrupted attempt — a pending record naming a set +// that exists. Adopting on that evidence let anyone take over any pack whose +// public link they could guess, and every share link is public. +// +// A create-only write on the name itself supplies the missing fact. The first +// claimant is the only user who can ever adopt a set under that name, so by the +// time createOrAdopt sees an existing set, "it is ours" has actually been +// proven rather than assumed. +// +// 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. + return existing, false, nil + + default: + // An earlier attempt was interrupted under a *different* name. Probing + // first is what makes this safe: if that set was already created, + // overwriting the record here would orphan it permanently — the set + // would exist, owned by this user, with adoption keyed on a slug the + // record no longer holds. + 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) { + // The old name is only adoptable if this caller reserved it too. They + // normally did — the pending record came from their own earlier run through + // reserveSlug — but adoption is the dangerous operation in this module, so + // it is re-proven 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 this + // caller cannot adopt under it; drop the dead intent and let them + // 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 set exists — adopt it rather than stranding it. + adopted := existing + adopted.Pending = false + if adopted.Count == 0 { + adopted.Count = 1 + } + if commitErr := s.commitPack(ctx, adopted); commitErr != nil { + log.Error("sticker_newpack_adopt_commit", "err", commitErr) + return Pack{}, true, reply(ctx, b, msg, genericFailure) + } + return Pack{}, true, reply(ctx, b, msg, fmt.Sprintf( + "You already have a pack (%s) from an earlier attempt — it has been restored.\n%s\nUse /delpack first if you want a different name.", + adopted.Slug, shareLink(adopted.Name))) + + 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) + } +} + +// createOrAdopt performs the Telegram-side creation for a claimed intent, or +// adopts a set an interrupted attempt already created. +// +// freshReservation reports that *this* invocation first claimed the name, which +// is what disqualifies adoption: see the err == nil branch. +func (s *state) createOrAdopt(ctx context.Context, b *bot.Bot, msg *models.Message, pack Pack, source stickerSource, freshReservation bool) error { + _, err := b.GetStickerSet(ctx, &bot.GetStickerSetParams{Name: pack.Name}) + switch { + case err == nil: + // A set exists under this name. Adopting it grants full control - + // DeleteStickerSet, DeleteStickerFromSet and SetStickerSetTitle are all + // keyed by set name alone, with no owner scoping - so this branch must + // prove the set is the caller's own interrupted attempt, not merely + // assume it. + // + // The reservation proves it only if it outlived the set. It does not + // always: the reservation lives in our store and the set lives at + // Telegram, and the store can be wiped (a restart on the in-memory + // backend does exactly that) while every pack survives. Then the + // evidence a takeover produces and the evidence a real resume produces + // are once again identical. + // + // freshReservation separates them without any new state. A genuine + // interrupted attempt reserved the name *before* creating the set, so it + // re-enters here having found its own reservation, never having made + // one. Claiming the name for the first time therefore proves the set + // under it is somebody else's. + if freshReservation { + log.Error("sticker_newpack_adopt_refused", "slug", pack.Slug, "owner", pack.OwnerID) + // Leave nothing behind. The intent records an attempt that is not + // going to happen, and the reservation names a set this caller has + // just been refused — holding either would deny the name to the + // set's real owner, who after a store wipe has to re-register it + // exactly as this caller tried to. + s.dropIntent(ctx, pack.OwnerID) + s.releaseSlug(ctx, pack.OwnerID, pack.Slug) + return reply(ctx, b, msg, slugTaken) + } + return s.finishNewPack(ctx, b, msg, pack, true) + + 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 and a re-run adopts the set. + 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, false) +} + +// 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, adopted bool) 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) + } + prefix := "Created" + if adopted { + prefix = "Finished an earlier attempt at" + } + return reply(ctx, b, msg, fmt.Sprintf("%s %s.\n%s\n\nAdd more with /addsticker while replying to a sticker.", + prefix, 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 + } + + 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 <title...>\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..599f4ed --- /dev/null +++ b/internal/modules/sticker/pack_handlers_test.go @@ -0,0 +1,691 @@ +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. +func TestNewPack_ResumesInterruptedAttempt(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setExists(rb) + s := newTestState() + + seedInterrupted(t, s, "mypack", testSet) + + if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + // The set already exists, so it is adopted rather than recreated. + if countMethod(rb, "createNewStickerSet") != 0 { + t.Errorf("methods = %v, want no create — the set already existed", methodsSent(rb)) + } + pack, _ := loadPack(t, s) + if pack.Pending { + t.Error("record still Pending after the resumed attempt") + } + if strings.Contains(rb.LastSent().Text(), "taken") { + t.Errorf("reply = %q, want a success message", rb.LastSent().Text()) + } +} + +// A pending record under a *different* slug whose set exists must be adopted, +// not overwritten: overwriting orphans that set permanently, because adoption +// keys on the slug matching. +func TestNewPack_DifferentSlugAdoptsExistingSet(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + setExists(rb) + s := newTestState() + + seedInterrupted(t, s, "oldslug", testSet) + + if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack newslug New", otherSet)); err != nil { + t.Fatalf("handleNewPack: %v", err) + } + + if countMethod(rb, "createNewStickerSet") != 0 { + t.Errorf("methods = %v, want no create", methodsSent(rb)) + } + pack, _ := loadPack(t, s) + if pack.Slug != "oldslug" { + t.Errorf("slug = %q, want the adopted oldslug — the old set must not be orphaned", pack.Slug) + } + if pack.Pending { + t.Error("adopted record still Pending") + } + if !strings.Contains(rb.LastSent().Text(), "oldslug") { + t.Errorf("reply = %q, want it to name the adopted pack", rb.LastSent().Text()) + } +} + +// 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) + } +} + +// 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") + } +} + +// A reservation the caller merely resumed must not be released when a later +// step bails — it predates this command. +func TestNewPack_ResumedReservationSurvivesABail(t *testing.T) { + rb := testutil.NewRecordingBot(t) + stubBotIdentity(rb) + rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) + 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 _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); !held { + t.Error("a resumed reservation was released on an unknown error; the name is now claimable by others while the set may exist") + } +} + +// The two tests below are the only coverage of the `created` flag itself. +// +// 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") + } +} 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_<botusername>" 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/<slug>_by_<bot> 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..8c66d08 --- /dev/null +++ b/internal/modules/sticker/state.go @@ -0,0 +1,118 @@ +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. Handlers run one at a time today, but +// the cron scheduler and the detached per-command stats hook run concurrently +// with them, so this is load-bearing rather than decorative. +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: "<pack> <title...>", + 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: "<emoji...>", + Handler: s.handleEditSticker, + }, + { + Name: "ordersticker", + Visibility: modules.VisibilityPublic, + Description: "Move a sticker in your pack to a position", + Parameters: "<position>", + 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: "<title...>", + 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 <emoji...>\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 <position>\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)) +} From 3dd0c6ccd407b701a5a24c3f66d6b96918fe6b5b Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 15:54:48 +0700 Subject: [PATCH 07/11] docs(plans): record sticker module delivery and review findings Mark phases 1-5 done and phase 6 partial: the code and docs are complete, but the live-token smoke checks and the deployed MODULES change are not, and three questions about Telegram's own behaviour stay open (file_id reuse across sets, the literal STICKERSET_INVALID string the self-heal paths match on, and whether a deleted short name is reclaimable). Add the three-lens review pass to the revision log, and correct two phase-03 checkboxes that shipped code contradicts - one of them ticked against text the file's own superseding note already retracted. --- .../phase-01-shared-prerequisites.md | 36 +- .../phase-02-store-setname-emoji.md | 42 +- .../phase-03-pack-lifecycle.md | 74 +-- .../phase-04-sticker-commands.md | 124 +++-- .../phase-05-photo-pipeline.md | 44 +- .../phase-06-wiring-docs.md | 32 +- plans/260824-1051-sticker-pack-module/plan.md | 131 +++++- ...tness-review-260825-1515-sticker-module.md | 423 ++++++++++++++++++ ...urity-review-260825-1515-sticker-module.md | 234 ++++++++++ .../test-review-260825-1515-sticker-module.md | 411 +++++++++++++++++ 10 files changed, 1414 insertions(+), 137 deletions(-) create mode 100644 plans/reports/correctness-review-260825-1515-sticker-module.md create mode 100644 plans/reports/security-review-260825-1515-sticker-module.md create mode 100644 plans/reports/test-review-260825-1515-sticker-module.md 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 index cf3de27..9123d2d 100644 --- a/plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md +++ b/plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md @@ -1,7 +1,7 @@ --- phase: 1 title: "Phase 1: Shared prerequisites" -status: todo +status: done priority: P1 effort: "4h" dependencies: [] @@ -122,26 +122,26 @@ is not entangled with sticker logic. ## Todo -- [ ] `recover()` in the command closure with `metrics.IncError("handler-panic")` -- [ ] `recover()` in the callback closure with guarded `AnswerCallbackQuery` -- [ ] Fix the stale `recover()` comment at `dispatcher.go:167` -- [ ] `RecordingBot.StubMethod(method, resultJSON)` -- [ ] `RecordingBot.FailMethodCode(method, errorCode, description)` -- [ ] Document that `FailMethod` produces a codeless failure -- [ ] `dispatcher_panic_test.go`: panicking command handler -- [ ] `dispatcher_panic_test.go`: panicking callback handler -- [ ] `recording_bot_test.go`: stubbed `getStickerSet` decodes into `models.StickerSet` -- [ ] `recording_bot_test.go`: `FailMethodCode` yields `bot.ErrorBadRequest` +- [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 -- [ ] A command handler that panics is recovered; the test process survives and the error metric increments -- [ ] A callback handler that panics is recovered and the callback query is still answered -- [ ] `rg "recover\(\)" internal/modules/dispatcher.go` returns two hits -- [ ] No comment in the repo claims a `recover()` exists in `webhook.go` -- [ ] `rb.StubMethod("getStickerSet", ...)` lets `b.GetStickerSet` return a populated `*models.StickerSet` with a nil error -- [ ] `rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")` produces an error satisfying `errors.Is(err, bot.ErrorBadRequest)` -- [ ] `go test ./...` passes with no changes to any existing test file other than additions +- [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 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 index 16a0f60..93b4335 100644 --- 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 @@ -1,7 +1,7 @@ --- phase: 2 title: "Phase 2: Store, set names, emoji parsing" -status: todo +status: done priority: P1 effort: "3h" dependencies: [1] @@ -146,29 +146,29 @@ from a replied sticker yields at most one element. ## Todo -- [ ] Define `Pack` incl. `Count` and `Pending`; assert no reserved-bson collision -- [ ] `packKey(ownerID)` and `getPack` returning a found flag -- [ ] `senderID` rejecting nil/zero/`IsBot`/`SenderChat` with an explanatory message -- [ ] `slugRe` validation incl. `__`, trailing `_`, 40-char cap -- [ ] `makeSetName` with 64-char guard and budget-reporting error -- [ ] `ownsSet` case-insensitive match against stored `Pack.Name` -- [ ] `usernameResolver` caching success but never failure, taking the handler's `b` -- [ ] `parseEmoji` cluster scanner with the 20-entry cap -- [ ] Four test files per the success criteria +- [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 -- [ ] `getPack` for owner A never returns owner B's pack -- [ ] `getPack` on an unknown owner returns `found == false` and a nil error -- [ ] No `List` call exists anywhere in the module -- [ ] Slug table rejects leading digit, `__`, trailing `_`, 2 chars, 41 chars -- [ ] `makeSetName` errors when `len(slug)+len("_by_"+username) > 64` -- [ ] `ownsSet` matches `MyPack_by_Bot` against a stored `mypack_by_bot` -- [ ] `ownsSet` returns false for a set name belonging to another bot -- [ ] A simulated bot username change does **not** break `ownsSet` for an existing pack -- [ ] `senderID` rejects `IsBot: true` and a non-nil `SenderChat`, each with zero store access -- [ ] `parseEmoji` handles joined input, ZWJ family, flag, keycap, skin tone; rejects plain text; errors above 20 -- [ ] `gofmt -l internal/modules/sticker` empty; `go test`/`go vet` clean +- [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 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 index 75ad607..be70c00 100644 --- a/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md +++ b/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md @@ -1,7 +1,7 @@ --- phase: 3 title: "Phase 3: Pack lifecycle commands" -status: todo +status: done priority: P1 effort: "7h" dependencies: [1, 2] @@ -42,6 +42,18 @@ Registry key is `sticker`; command names stay unprefixed — the registry keys c `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:<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 <pack> <title...>` — write-ahead intent `<pack>` appears here and nowhere else in the module. It fixes the permanent share URL, and @@ -209,39 +221,39 @@ into prose by the Bot API server; everything else arrives as `Bad Request: <CODE ## Todo -- [ ] `state.go` with store, pending view, resolver, locks, nowFn, `handlerTimeout` -- [ ] `sticker.go` factory registering 4 commands + callback prefix -- [ ] `errors.go`: `replyAPIError` code table + `isStickerSetMissing` -- [ ] `/mypack` reading `Count`, marking a pending record, zero API calls -- [ ] `/newpack` steps 1-7 incl. `PutVersioned` intent, three `ErrConflict` branches, adoption -- [ ] Different-slug pending branch probes `GetStickerSet(oldName)` before overwriting -- [ ] `/renamepack` reply: new title, unchanged link, and the /delpack + /newpack route -- [ ] `/delpack` confirm prompt: title, sticker count, link, permanence -- [ ] `pending_delete.go` with TTL, chat/message binding, opaque id -- [ ] `/delpack` naming the pack in its confirm prompt -- [ ] `delpack_callback.go` with expiry, binding, nil-message guard, single-use -- [ ] Record self-heal on `isStickerSetMissing` across commands -- [ ] Tests per the success criteria +- [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 -- [ ] `/mypack` records **zero** entries in `RecordingBot.Sent()` -- [ ] A second `/newpack` with a confirmed pack present is refused, names the existing slug, and makes zero API calls -- [ ] Interrupted `/newpack` (pending record, same slug, set exists) completes on re-run and does not report the slug taken -- [ ] Interrupted `/newpack` with a *different* slug where the old set **exists** adopts the old set and refuses the new slug, leaving nothing orphaned -- [ ] Interrupted `/newpack` with a *different* slug where the old set is **missing** replaces the pending record and proceeds -- [ ] `/newpack` where `GetStickerSet` fails with a non-missing error aborts, deletes the pending record, and never calls `CreateNewStickerSet` -- [ ] `/newpack` uses `PutVersioned(…, 0, …)`; the create path never calls `Put` for a new record -- [ ] `/renamepack` with no pack replies "you don't have a pack yet" and makes zero API calls -- [ ] `/delpack` confirm after `ExpiresAt` is refused as expired, with no `DeleteStickerSet` -- [ ] `/delpack` confirm from a different `From.ID` is refused, with no `DeleteStickerSet` -- [ ] `/delpack` confirm with a nil `CallbackQuery.Message.Message` is handled without panic -- [ ] Pressing the same confirm twice deletes once; the second press reports already-used -- [ ] Callback data is asserted ≤ 64 bytes -- [ ] A command receiving `STICKERSET_INVALID` deletes the stale record, unblocking `/newpack` -- [ ] Title of 65 chars rejected locally, before any API call -- [ ] `/delpack` confirm text contains the pack title, the sticker count, and the share link -- [ ] `/renamepack` reply names the `/delpack` + `/newpack` route +- [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 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 index 1cb3f3e..39bc89b 100644 --- a/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md +++ b/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md @@ -1,7 +1,7 @@ --- phase: 4 title: "Phase 4: Sticker commands (reply path)" -status: todo +status: done priority: P1 effort: "4h" dependencies: [1, 2, 3] @@ -32,13 +32,13 @@ All handlers follow the plan's cross-cutting rules. ### Shared resolution ```go -// source of a NEW sticker: whatever the replied message carries +// 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 "" when a photo needs uploading - photo *photoRef // Phase 5; nil on the sticker path - emoji []string // from the replied sticker; at most one element + 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(msg *models.Message) (stickerSource, error) +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 { @@ -48,19 +48,37 @@ type ownedSticker struct { 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. `getPack(ownerID)` — **one `Get`**. Absent → the caller has no pack. -4. `ownsSet(pack, sticker.SetName)` (Phase 2) — case-insensitive against the **stored** +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. -5. **Steps 3 and 4 must produce byte-identical reply text.** Distinct messages would let a user +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. -6. Reject stickers with `IsAnimated` or `IsVideo`. Static-only module; defence in depth. 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 @@ -75,6 +93,14 @@ from a replied sticker holds at most one element. 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. @@ -84,10 +110,27 @@ by `parseEmoji` and reported as a usage error rather than silently read as a pac 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. +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. @@ -104,6 +147,21 @@ Corrected: **do not probe.** Decrement the count and stop. If the set really is 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 @@ -136,27 +194,35 @@ size and a local copy would go stale. Its error goes through `replyAPIError`. ## Todo -- [ ] `resolveSource` sticker branch (photo branch stubbed for Phase 5) -- [ ] `resolveOwned` using `getPack` + `ownsSet`, with the 6-step gate and uniform refusal -- [ ] `/addsticker` with emoji precedence and `Count` increment -- [ ] `/delsticker` with `Count` decrement and **no** probe -- [ ] `/editsticker` requiring at least one emoji -- [ ] `/ordersticker` rejecting negatives locally only -- [ ] Register all four with `Parameters` metadata -- [ ] `resolve_test.go`, `sticker_handlers_test.go` +- [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 -- [ ] Each command's happy path asserts the expected method in `RecordingBot.Sent()` -- [ ] Missing reply, non-sticker reply, and empty `set_name` each produce a usage error with zero API calls -- [ ] "No pack yet" and "sticker from another bot's set" produce **byte-identical** reply text, asserted by comparing the two replies to each other -- [ ] A sticker whose `SetName` differs only in case from the stored `Pack.Name` resolves successfully -- [ ] `/addsticker` with a non-emoji argument is rejected by `parseEmoji`, not silently reinterpreted -- [ ] `/ordersticker -1` rejected locally; `/ordersticker 999` reaches the API -- [ ] `/editsticker` with no emoji rejected locally -- [ ] `/delsticker` makes exactly one API call and never deletes the `Pack` record -- [ ] `/addsticker` and `/delsticker` move `Count` by exactly one, floored at 0 -- [ ] Animated/video sticker reply rejected before any API call +- [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 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 index b3ca356..85df045 100644 --- a/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md +++ b/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md @@ -1,7 +1,7 @@ --- phase: 5 title: "Phase 5: Photo pipeline and pack icon" -status: todo +status: done priority: P1 effort: "8h" dependencies: [1, 2, 3, 4] @@ -50,6 +50,10 @@ 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. @@ -159,28 +163,28 @@ which a 512px sticker's `file_id` does not meet. Confirm in Phase 6's smoke test ## Todo -- [ ] Add `golang.org/x/image`; verify `go mod tidy` produces no diff -- [ ] `download.go` with 2 MB `LimitReader`, own client timeout, sentinel conversion -- [ ] `classify(err)` returning a coarse label that cannot contain a URL -- [ ] `toStickerPNG` with DecodeConfig guard, CatmullRom scale, PNG size ladder -- [ ] `toThumbnailPNG` at exactly 100×100 with transparent padding -- [ ] Photo/document source selection with mime allowlist and 2 MB pre-check -- [ ] Wire photo branch into `/addsticker` and `/newpack` -- [ ] `/setpackicon` handler and registration -- [ ] `image_test.go` with in-test generated fixtures (no committed binaries) -- [ ] `download_test.go` asserting no token or URL in any returned error +- [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 -- [ ] 1024×512 → 512×256; 300×900 → 171×512; 512×512 → 512×512 -- [ ] 1×5000 extreme aspect: short edge clamped to ≥1, no panic, no zero-dimension image -- [ ] Alpha channel preserved through the resize -- [ ] Source above 2 MB rejected with zero HTTP requests made -- [ ] Decoded dimensions above 4096×4096 rejected before pixel allocation -- [ ] Unsupported document mime rejected before download -- [ ] `toThumbnailPNG` output is exactly 100×100 -- [ ] **A forced transport failure against an `httptest` server yields an error whose text contains neither `"bot"` nor the URL** — asserted, not assumed -- [ ] `go mod tidy && git diff --exit-code go.mod go.sum` clean +- [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 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 index 4ba2815..6ec2c7f 100644 --- a/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md +++ b/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md @@ -1,7 +1,7 @@ --- phase: 6 title: "Phase 6: Wiring, menu, docs" -status: todo +status: partial priority: P2 effort: "4h" dependencies: [1, 2, 3, 4, 5] @@ -100,6 +100,15 @@ modules' descriptions. `[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 @@ -146,20 +155,21 @@ dispatcher hook (`dispatcher.go:80-84`). ## Todo - [ ] Set `MODULES` explicitly in the deployed environment and verify -- [ ] Update `.env.example` with the explicit list and a why-comment -- [ ] Confirm `mypack` is free in the registry alongside the other eight -- [ ] Draft nine descriptions within the measured budget and re-measure `RenderHelp` -- [ ] Add `"sticker": sticker.New` to `factories()` -- [ ] Add nine entries to `expectedParameters` -- [ ] README module-table row + `### Sticker packs` section -- [ ] `docs/sticker-packs.md` -- [ ] Full validation gate +- [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 -- [ ] `gofmt -l .` empty; `go vet ./...` clean; `go test ./...` passes; `golangci-lint run` clean -- [ ] `RenderHelp` stays under 4096 runes with all nine commands registered +- [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 diff --git a/plans/260824-1051-sticker-pack-module/plan.md b/plans/260824-1051-sticker-pack-module/plan.md index ccef672..6c710fa 100644 --- a/plans/260824-1051-sticker-pack-module/plan.md +++ b/plans/260824-1051-sticker-pack-module/plan.md @@ -1,7 +1,7 @@ --- 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: pending +status: partial priority: P2 effort: "" tags: ["sticker", "telegram-bot", "module"] @@ -190,12 +190,12 @@ structs, so under the current harness they can only ever **error**. `FailMethod` | # | Phase | Status | |---|-------|--------| -| 1 | [Phase 1: Shared prerequisites](./phase-01-shared-prerequisites.md) | Pending | -| 2 | [Phase 2: Store, set names, emoji parsing](./phase-02-store-setname-emoji.md) | Pending | -| 3 | [Phase 3: Pack lifecycle commands](./phase-03-pack-lifecycle.md) | Pending | -| 4 | [Phase 4: Sticker commands (reply path)](./phase-04-sticker-commands.md) | Pending | -| 5 | [Phase 5: Photo pipeline and pack icon](./phase-05-photo-pipeline.md) | Pending | -| 6 | [Phase 6: Wiring, menu, docs](./phase-06-wiring-docs.md) | Pending | +| 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 @@ -261,6 +261,7 @@ Public module creating durable Telegram-side objects on a single-threaded dispat | 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 @@ -298,6 +299,42 @@ per-user pack limit is one (former O2). ## Design Revisions +### 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 @@ -351,6 +388,86 @@ before an interruption. It now probes `GetStickerSet(oldName)` first and adopts 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. 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. From c4c8c3088ebb3af108454e1483888745b26ab847 Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 16:28:11 +0700 Subject: [PATCH 08/11] fix(sticker): never adopt an existing pack Two ordinary /newpack commands could take over a stranger's pack. The first probe returns an inconclusive error, which correctly keeps the reservation so the user can retry - but that turned a fresh claim into a resumed one and defeated the guard that made adoption conditional. resolveStaleIntent had a second adopt path that never consulted the guard at all. Both are reproduced by tests added here. This is the fourth failure of the same mechanism, and it is structural. Adoption must prove "this set is mine to finish" from local state, and local state is what a restart on the in-memory backend erases while the packs at Telegram survive. With the proof gone, a genuine interrupted attempt and a stranger naming a public share link are indistinguishable. Remove adoption entirely. /newpack refuses any name a set already occupies, and leaves no intent or reservation behind when it does. A pending record is not evidence of ownership either: anyone can make one naming any set, and DeleteStickerSet is keyed by set name, which Telegram authorises for every set this bot created. /delpack therefore clears a pending record locally and contacts Telegram only for a confirmed one. The cost is that a crash between creating a set and recording it strands that set. That is documented rather than mitigated - every mitigation available is the mechanism that just failed. Also drop a test whose name claimed to pin the resumed-reservation distinction but bailed past the code that implements it, rename a delpack test after the guard that actually stops a foreign presser, and pin releaseSlug's ownership check and detached read - the latter needed a context-honouring store, since the in-memory one ignores cancellation and made the first version of that test vacuous. --- docs/sticker-packs.md | 39 ++- internal/modules/sticker/delpack_callback.go | 30 ++ .../modules/sticker/delpack_callback_test.go | 9 +- internal/modules/sticker/pack.go | 14 +- internal/modules/sticker/pack_handlers.go | 141 +++++----- .../modules/sticker/pack_handlers_test.go | 251 ++++++++++++++--- plans/260824-1051-sticker-pack-module/plan.md | 28 ++ ...ify-security-260825-1558-sticker-round4.md | 227 +++++++++++++++ ...verify-tests-260825-1558-sticker-round4.md | 259 ++++++++++++++++++ 9 files changed, 854 insertions(+), 144 deletions(-) create mode 100644 plans/reports/verify-security-260825-1558-sticker-round4.md create mode 100644 plans/reports/verify-tests-260825-1558-sticker-round4.md diff --git a/docs/sticker-packs.md b/docs/sticker-packs.md index 5482d47..a982ab3 100644 --- a/docs/sticker-packs.md +++ b/docs/sticker-packs.md @@ -97,9 +97,8 @@ 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 the point: the claim is what proves, on a later re-run, that an -existing set under that name is yours to finish rather than someone else's to -take. +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 @@ -107,6 +106,9 @@ 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. @@ -114,17 +116,28 @@ is not guaranteed to be usable again by anyone, including its previous owner. "that sticker isn't from your pack" produce the exact same reply. Distinct wording would let anyone probe which sets exist under this bot. -**An interrupted `/newpack` can be finished.** The bot records your intent -before calling Telegram, so if a deploy or crash lands mid-creation, re-running -the same `/newpack` command completes it instead of reporting the name taken. -`/mypack` marks an unfinished attempt so it is visible rather than mysterious. +**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. -This depends on the bot still holding your claim to the name. If its storage has -been wiped since — which is what a restart does when no database is configured — -the claim is gone while the pack at Telegram is not, and `/newpack` reports the -name as taken rather than adopting a set it can no longer prove is yours. -Recovering a pack in that state needs operator help. Run this module against a -real database, not the in-memory backend. +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 diff --git a/internal/modules/sticker/delpack_callback.go b/internal/modules/sticker/delpack_callback.go index 233aed9..08363a3 100644 --- a/internal/modules/sticker/delpack_callback.go +++ b/internal/modules/sticker/delpack_callback.go @@ -38,6 +38,20 @@ func (s *state) handleDelPack(ctx context.Context, b *bot.Bot, update *models.Up 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) @@ -115,6 +129,9 @@ func (s *state) handleDelPackCallback(ctx context.Context, b *bot.Bot, update *m 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.") } @@ -154,6 +171,19 @@ func (s *state) handleDelPackCallback(ctx context.Context, b *bot.Bot, update *m defer s.lockUser(action.OwnerID)() + // Re-check under the lock that the record still authorises a Telegram-side + // delete. handleDelPack refuses to prompt for a pending record, so this + // should be unreachable — but the guard belongs on the destructive + // operation rather than only on the path that normally reaches it. + if current, found, err := getPack(ctx, s.store, action.OwnerID); err != nil { + log.Error("sticker_delpack_recheck", "err", err) + return answerCallback(ctx, b, query.ID, "Could not confirm right now. Try /delpack again.") + } else 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 attempt was never confirmed, so nothing was deleted at Telegram.") + } + // 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 { diff --git a/internal/modules/sticker/delpack_callback_test.go b/internal/modules/sticker/delpack_callback_test.go index d4249b2..fedf070 100644 --- a/internal/modules/sticker/delpack_callback_test.go +++ b/internal/modules/sticker/delpack_callback_test.go @@ -106,7 +106,14 @@ func TestDelPackCallback_HappyPath(t *testing.T) { // Identity comes from From.ID, never from the payload — the payload is // client-controlled. -func TestDelPackCallback_RejectsOtherUser(t *testing.T) { +// 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) diff --git a/internal/modules/sticker/pack.go b/internal/modules/sticker/pack.go index 51c66b9..edd56f2 100644 --- a/internal/modules/sticker/pack.go +++ b/internal/modules/sticker/pack.go @@ -38,12 +38,16 @@ type PackStore = storage.DocStore[Pack] // 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. Adopting on that evidence -// alone let any user take over any pack whose public link they could guess. +// side: a pending record naming a set that exists. // -// The reservation is the missing half. It is claimed with a create-only write -// before Telegram is touched, so the first claimant of a name is the only user -// who can ever adopt a set under it. +// 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"` diff --git a/internal/modules/sticker/pack_handlers.go b/internal/modules/sticker/pack_handlers.go index 65191bd..27faaba 100644 --- a/internal/modules/sticker/pack_handlers.go +++ b/internal/modules/sticker/pack_handlers.go @@ -39,9 +39,10 @@ const ( // - 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; treating it -// as proof of ownership was a pack-takeover hole. The reservation is what makes -// "this set is mine to adopt" a fact rather than an assumption. +// 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() @@ -122,22 +123,21 @@ func (s *state) handleNewPack(ctx context.Context, b *bot.Bot, update *models.Up return err } - return s.createOrAdopt(ctx, b, msg, claimed, source, created) + 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. That is not enough to make adoption safe: a user with no -// pack who names someone else's slug produces exactly the same evidence as a -// user resuming their own interrupted attempt — a pending record naming a set -// that exists. Adopting on that evidence let anyone take over any pack whose -// public link they could guess, and every share link is public. +// 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 supplies the missing fact. The first -// claimant is the only user who can ever adopt a set under that name, so by the -// time createOrAdopt sees an existing set, "it is ours" has actually been -// proven rather than assumed. +// 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 @@ -254,11 +254,9 @@ func (s *state) claimSlug(ctx context.Context, b *bot.Bot, msg *models.Message, return existing, false, nil default: - // An earlier attempt was interrupted under a *different* name. Probing - // first is what makes this safe: if that set was already created, - // overwriting the record here would orphan it permanently — the set - // would exist, owned by this user, with adoption keyed on a slug the - // record no longer holds. + // 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) } } @@ -266,19 +264,19 @@ func (s *state) claimSlug(ctx context.Context, b *bot.Bot, msg *models.Message, // 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) { - // The old name is only adoptable if this caller reserved it too. They - // normally did — the pending record came from their own earlier run through - // reserveSlug — but adoption is the dangerous operation in this module, so - // it is re-proven rather than inferred from the pending record. + // 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 this - // caller cannot adopt under it; drop the dead intent and let them - // proceed with the name they actually asked for. + // 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) @@ -289,19 +287,21 @@ func (s *state) resolveStaleIntent(ctx context.Context, b *bot.Bot, msg *models. _, err = b.GetStickerSet(ctx, &bot.GetStickerSetParams{Name: existing.Name}) switch { case err == nil: - // The old set exists — adopt it rather than stranding it. - adopted := existing - adopted.Pending = false - if adopted.Count == 0 { - adopted.Count = 1 - } - if commitErr := s.commitPack(ctx, adopted); commitErr != nil { - log.Error("sticker_newpack_adopt_commit", "err", commitErr) + // 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) } - return Pack{}, true, reply(ctx, b, msg, fmt.Sprintf( - "You already have a pack (%s) from an earlier attempt — it has been restored.\n%s\nUse /delpack first if you want a different name.", - adopted.Slug, shareLink(adopted.Name))) + 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 @@ -322,45 +322,29 @@ func (s *state) resolveStaleIntent(ctx context.Context, b *bot.Bot, msg *models. } } -// createOrAdopt performs the Telegram-side creation for a claimed intent, or -// adopts a set an interrupted attempt already created. +// createPack performs the Telegram-side creation for a claimed intent. // -// freshReservation reports that *this* invocation first claimed the name, which -// is what disqualifies adoption: see the err == nil branch. -func (s *state) createOrAdopt(ctx context.Context, b *bot.Bot, msg *models.Message, pack Pack, source stickerSource, freshReservation bool) error { +// 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: - // A set exists under this name. Adopting it grants full control - - // DeleteStickerSet, DeleteStickerFromSet and SetStickerSetTitle are all - // keyed by set name alone, with no owner scoping - so this branch must - // prove the set is the caller's own interrupted attempt, not merely - // assume it. - // - // The reservation proves it only if it outlived the set. It does not - // always: the reservation lives in our store and the set lives at - // Telegram, and the store can be wiped (a restart on the in-memory - // backend does exactly that) while every pack survives. Then the - // evidence a takeover produces and the evidence a real resume produces - // are once again identical. - // - // freshReservation separates them without any new state. A genuine - // interrupted attempt reserved the name *before* creating the set, so it - // re-enters here having found its own reservation, never having made - // one. Claiming the name for the first time therefore proves the set - // under it is somebody else's. - if freshReservation { - log.Error("sticker_newpack_adopt_refused", "slug", pack.Slug, "owner", pack.OwnerID) - // Leave nothing behind. The intent records an attempt that is not - // going to happen, and the reservation names a set this caller has - // just been refused — holding either would deny the name to the - // set's real owner, who after a store wipe has to re-register it - // exactly as this caller tried to. - s.dropIntent(ctx, pack.OwnerID) - s.releaseSlug(ctx, pack.OwnerID, pack.Slug) - return reply(ctx, b, msg, slugTaken) - } - return s.finishNewPack(ctx, b, msg, pack, true) + // 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. @@ -390,18 +374,19 @@ func (s *state) createOrAdopt(ctx context.Context, b *bot.Bot, msg *models.Messa 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 and a re-run adopts the set. + // 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, false) + 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, adopted bool) error { +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 @@ -410,12 +395,8 @@ func (s *state) finishNewPack(ctx context.Context, b *bot.Bot, msg *models.Messa log.Error("sticker_newpack_commit", "err", err) return reply(ctx, b, msg, genericFailure) } - prefix := "Created" - if adopted { - prefix = "Finished an earlier attempt at" - } - return reply(ctx, b, msg, fmt.Sprintf("%s %s.\n%s\n\nAdd more with /addsticker while replying to a sticker.", - prefix, pack.Title, shareLink(pack.Name))) + 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 diff --git a/internal/modules/sticker/pack_handlers_test.go b/internal/modules/sticker/pack_handlers_test.go index 599f4ed..cda1199 100644 --- a/internal/modules/sticker/pack_handlers_test.go +++ b/internal/modules/sticker/pack_handlers_test.go @@ -80,58 +80,67 @@ func TestNewPack_SecondPackRefusedWithoutAPICall(t *testing.T) { // 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. -func TestNewPack_ResumesInterruptedAttempt(t *testing.T) { +// 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(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { t.Fatalf("handleNewPack: %v", err) } - // The set already exists, so it is adopted rather than recreated. if countMethod(rb, "createNewStickerSet") != 0 { - t.Errorf("methods = %v, want no create — the set already existed", methodsSent(rb)) + t.Errorf("methods = %v, want no create — a set already exists under that name", methodsSent(rb)) } - pack, _ := loadPack(t, s) - if pack.Pending { - t.Error("record still Pending after the resumed attempt") + if got := rb.LastSent().Text(); !strings.Contains(got, slugTaken) { + t.Errorf("reply = %q, want the name-taken refusal", got) } - if strings.Contains(rb.LastSent().Text(), "taken") { - t.Errorf("reply = %q, want a success message", rb.LastSent().Text()) + 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 must be adopted, -// not overwritten: overwriting orphans that set permanently, because adoption -// keys on the slug matching. -func TestNewPack_DifferentSlugAdoptsExistingSet(t *testing.T) { +// 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(context.Background(), rb.Bot, stickerReply("/newpack newslug New", otherSet)); err != nil { + if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newslug New", otherSet)); err != nil { t.Fatalf("handleNewPack: %v", err) } - if countMethod(rb, "createNewStickerSet") != 0 { - t.Errorf("methods = %v, want no create", methodsSent(rb)) + if pack, found := loadPack(t, s); found && pack.Slug == "oldslug" && !pack.Pending { + t.Errorf("adopted the old set: %+v", pack) } - pack, _ := loadPack(t, s) - if pack.Slug != "oldslug" { - t.Errorf("slug = %q, want the adopted oldslug — the old set must not be orphaned", pack.Slug) - } - if pack.Pending { - t.Error("adopted record still Pending") - } - if !strings.Contains(rb.LastSent().Text(), "oldslug") { - t.Errorf("reply = %q, want it to name the adopted pack", rb.LastSent().Text()) + // 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") } } @@ -568,26 +577,12 @@ func TestNewPack_ReservationReleasedWhenClaimFails(t *testing.T) { } } -// A reservation the caller merely resumed must not be released when a later -// step bails — it predates this command. -func TestNewPack_ResumedReservationSurvivesABail(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) - 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 _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); !held { - t.Error("a resumed reservation was released on an unknown error; the name is now claimable by others while the set may exist") - } -} - -// The two tests below are the only coverage of the `created` flag itself. +// 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 @@ -689,3 +684,169 @@ func TestNewPack_WipedStoreCannotAdoptSurvivingPack(t *testing.T) { 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) +} diff --git a/plans/260824-1051-sticker-pack-module/plan.md b/plans/260824-1051-sticker-pack-module/plan.md index 6c710fa..04536e4 100644 --- a/plans/260824-1051-sticker-pack-module/plan.md +++ b/plans/260824-1051-sticker-pack-module/plan.md @@ -299,6 +299,34 @@ per-user pack limit is one (former O2). ## Design Revisions +### 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: 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? From c83bcfaebfd6bfe117278f448d0b7a4eed1ccf86 Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 16:49:37 +0700 Subject: [PATCH 09/11] fix(sticker): prove authority before a confirmed pack delete A /delpack confirmation outlived the record that authorised it. The under-lock re-check 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. Reachable with ordinary commands and no attacker: run /delpack without pressing, let the pack disappear from Telegram's side so a self-heal frees the name, let another user claim it, then press. DeleteStickerSet is keyed by set name, which Telegram authorises for every set this bot created, so the press destroys whoever holds the name at that moment. Invert the guard: delete only when a confirmed record still names this exact set. A check phrased as "which states do I refuse" cannot fail closed against a state nobody enumerated. Dropping a pack record now also clears any outstanding confirmation, so a dead prompt stops existing rather than merely being refused on use. This also stops the reservation leaking when a confirmed delete lands on a record that has moved on, since that case no longer reaches Telegram. Alongside: - Resuming an interrupted /newpack discarded a retyped title and reported success quoting the old one. - TestNewPack_DifferentSlugReplacesDeadIntent was named for releasing a dead name and never asserted it. - 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. The lock stays for the read-modify-write pattern; a wrong reason for a right guard misleads the next reader. --- internal/modules/sticker/delpack_callback.go | 27 +- .../modules/sticker/delpack_callback_test.go | 59 +++- internal/modules/sticker/pack_handlers.go | 15 +- .../modules/sticker/pack_handlers_test.go | 60 ++++ internal/modules/sticker/state.go | 15 +- plans/260824-1051-sticker-pack-module/plan.md | 24 ++ .../verify-260825-1630-sticker-round5.md | 260 ++++++++++++++++++ 7 files changed, 447 insertions(+), 13 deletions(-) create mode 100644 plans/reports/verify-260825-1630-sticker-round5.md diff --git a/internal/modules/sticker/delpack_callback.go b/internal/modules/sticker/delpack_callback.go index 08363a3..3688f16 100644 --- a/internal/modules/sticker/delpack_callback.go +++ b/internal/modules/sticker/delpack_callback.go @@ -171,17 +171,30 @@ func (s *state) handleDelPackCallback(ctx context.Context, b *bot.Bot, update *m defer s.lockUser(action.OwnerID)() - // Re-check under the lock that the record still authorises a Telegram-side - // delete. handleDelPack refuses to prompt for a pending record, so this - // should be unreachable — but the guard belongs on the destructive - // operation rather than only on the path that normally reaches it. - if current, found, err := getPack(ctx, s.store, action.OwnerID); err != nil { + // 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.") - } else if found && current.Pending && ownsSet(current, action.SetName) { + } + 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 attempt was never confirmed, so nothing was deleted at Telegram.") + 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 diff --git a/internal/modules/sticker/delpack_callback_test.go b/internal/modules/sticker/delpack_callback_test.go index fedf070..673c687 100644 --- a/internal/modules/sticker/delpack_callback_test.go +++ b/internal/modules/sticker/delpack_callback_test.go @@ -256,7 +256,8 @@ func TestDelPackCallback_IgnoresNonCallbackUpdate(t *testing.T) { // pack and orphaning it permanently. func TestDelPackCallback_StalePressLeavesTheCurrentPackAlone(t *testing.T) { rb := testutil.NewRecordingBot(t) - // The old set no longer exists: this is the deleted-then-recreated flow. + // 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() @@ -277,6 +278,14 @@ func TestDelPackCallback_StalePressLeavesTheCurrentPackAlone(t *testing.T) { 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") @@ -394,3 +403,51 @@ func TestDelPackCallback_ReleasesTheName(t *testing.T) { t.Error("the name is still reserved after the pack was deleted") } } + +// A confirmation must never outlive the authority it was issued under. +// +// Reachable with ordinary commands and no attacker: U runs /delpack and does +// not press; U's pack then disappears from Telegram's side, so a self-heal +// clears U's record and frees the name; V legitimately claims that name; U +// finally presses. DeleteStickerSet is keyed by set name, which Telegram +// authorises for every set this bot created, so the press landed on V's pack. +// +// The re-check that was supposed to stop this was written as a blocklist — it +// refused only a *pending* record still naming this set, and fell through when +// the record was missing or had moved on. Authority must be proven, not +// disproven. +func TestDelPackCallback_StalePressCannotDeleteTheNextHolder(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + ctx := context.Background() + + // U has a confirmed pack and a live confirmation prompt for it. + seedPack(t, s, 3) + action := seedPendingDelete(t, s, nil) + + // U's set vanishes at Telegram; a self-heal clears the record and the name. + s.dropPackRecord(ctx, testUser) + + // V now legitimately holds that name, with a set behind it. + const victim = int64(99) + if err := s.slugs.Put(ctx, slugKey("mypack"), + SlugReservation{Slug: "mypack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed victim reservation: %v", err) + } + 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 pack: %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 — the press destroyed whoever holds that name now", 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/pack_handlers.go b/internal/modules/sticker/pack_handlers.go index 27faaba..0d6a7ff 100644 --- a/internal/modules/sticker/pack_handlers.go +++ b/internal/modules/sticker/pack_handlers.go @@ -250,8 +250,14 @@ func (s *state) claimSlug(ctx context.Context, b *bot.Bot, msg *models.Message, existing.Slug, shareLink(existing.Name))) case existing.Slug == slug: - // Our own interrupted attempt for this exact name: resume it. - return existing, false, nil + // 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 + resumed.Name = intent.Name + return resumed, false, nil default: // An earlier attempt was interrupted under a *different* name. Probe @@ -513,6 +519,11 @@ func (s *state) dropPackRecord(ctx context.Context, ownerID int64) { 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) } diff --git a/internal/modules/sticker/pack_handlers_test.go b/internal/modules/sticker/pack_handlers_test.go index cda1199..08d782f 100644 --- a/internal/modules/sticker/pack_handlers_test.go +++ b/internal/modules/sticker/pack_handlers_test.go @@ -164,6 +164,12 @@ func TestNewPack_DifferentSlugReplacesDeadIntent(t *testing.T) { 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 @@ -850,3 +856,57 @@ func (c ctxHonouringSlugs) List(ctx context.Context, prefix string) ([]string, e } 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"]) + } + } +} diff --git a/internal/modules/sticker/state.go b/internal/modules/sticker/state.go index 8c66d08..eb1901e 100644 --- a/internal/modules/sticker/state.go +++ b/internal/modules/sticker/state.go @@ -79,9 +79,18 @@ func mediaContext(ctx context.Context) (context.Context, context.CancelFunc) { return chathelper.FetchContext(ctx) } -// lockUser serialises a user's mutations. Handlers run one at a time today, but -// the cron scheduler and the detached per-command stats hook run concurrently -// with them, so this is load-bearing rather than decorative. +// 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)) } diff --git a/plans/260824-1051-sticker-pack-module/plan.md b/plans/260824-1051-sticker-pack-module/plan.md index 04536e4..c4e9d5a 100644 --- a/plans/260824-1051-sticker-pack-module/plan.md +++ b/plans/260824-1051-sticker-pack-module/plan.md @@ -299,6 +299,30 @@ 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 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. From 562e43974fc03f87b097d45a1a5155597cd6cb36 Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 17:09:19 +0700 Subject: [PATCH 10/11] test(sticker): pin the delpack authority guard, keep the stored set name The previous commit's regression test never reached the guard it was named for. It broke the pack record with dropPackRecord, which now also clears the confirmation, so the callback returned at the pending.Get miss long before the allowlist. The test passed with the entire guard reverted - shipping the fix with its own detector inoperative, which is the defect that let five earlier rounds report a false clean. Replace it with a table that leaves the confirmation intact and breaks the record three ways, one per disjunct: record gone, record unconfirmed, record moved on. Reverting the guard now fails two cases; each disjunct was mutated individually. The !found disjunct is an equivalent mutant: ownsSet already returns false for a zero-value record's empty Name, so no test can kill it. Kept and commented, because that redundancy is an accident of ownsSet's empty-string guard rather than something this check should rely on. Also revert the set-name half of the previous commit's resume change. Carrying the retyped title is right; re-deriving Pack.Name was not. The name comes from the bot username, which can change at BotFather, and the stored one identifies the set the interrupted attempt may already have created - refreshing it orphaned that set and aimed later commands at a different name, contradicting ownsSet's own documented rule. Pinned. --- internal/modules/sticker/delpack_callback.go | 4 + .../modules/sticker/delpack_callback_test.go | 112 ++++--- internal/modules/sticker/pack_handlers.go | 5 +- .../modules/sticker/pack_handlers_test.go | 31 ++ .../verify-260825-1700-sticker-round6.md | 310 ++++++++++++++++++ 5 files changed, 423 insertions(+), 39 deletions(-) create mode 100644 plans/reports/verify-260825-1700-sticker-round6.md diff --git a/internal/modules/sticker/delpack_callback.go b/internal/modules/sticker/delpack_callback.go index 3688f16..10756de 100644 --- a/internal/modules/sticker/delpack_callback.go +++ b/internal/modules/sticker/delpack_callback.go @@ -190,6 +190,10 @@ func (s *state) handleDelPackCallback(ctx context.Context, b *bot.Bot, update *m 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) diff --git a/internal/modules/sticker/delpack_callback_test.go b/internal/modules/sticker/delpack_callback_test.go index 673c687..6397941 100644 --- a/internal/modules/sticker/delpack_callback_test.go +++ b/internal/modules/sticker/delpack_callback_test.go @@ -406,48 +406,84 @@ func TestDelPackCallback_ReleasesTheName(t *testing.T) { // A confirmation must never outlive the authority it was issued under. // -// Reachable with ordinary commands and no attacker: U runs /delpack and does -// not press; U's pack then disappears from Telegram's side, so a self-heal -// clears U's record and frees the name; V legitimately claims that name; U -// finally presses. DeleteStickerSet is keyed by set name, which Telegram -// authorises for every set this bot created, so the press landed on V's pack. +// 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 re-check that was supposed to stop this was written as a blocklist — it -// refused only a *pending* record still naming this set, and fell through when -// the record was missing or had moved on. Authority must be proven, not -// disproven. -func TestDelPackCallback_StalePressCannotDeleteTheNextHolder(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - ctx := context.Background() - - // U has a confirmed pack and a live confirmation prompt for it. - seedPack(t, s, 3) - action := seedPendingDelete(t, s, nil) - - // U's set vanishes at Telegram; a self-heal clears the record and the name. - s.dropPackRecord(ctx, testUser) - - // V now legitimately holds that name, with a set behind it. +// 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) - if err := s.slugs.Put(ctx, slugKey("mypack"), - SlugReservation{Slug: "mypack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil { - t.Fatalf("seed victim reservation: %v", err) - } - 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 pack: %v", err) + + 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) + } + }, + }, } - if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(action, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + ctx := context.Background() - if n := countMethod(rb, "deleteStickerSet"); n != 0 { - t.Errorf("deleteStickerSet calls = %d, want 0 — the press destroyed whoever holds that name now", n) - } - if _, _, err := s.store.Get(ctx, packKey(victim)); err != nil { - t.Errorf("victim's pack record damaged: %v", err) + 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/pack_handlers.go b/internal/modules/sticker/pack_handlers.go index 0d6a7ff..6cfaa53 100644 --- a/internal/modules/sticker/pack_handlers.go +++ b/internal/modules/sticker/pack_handlers.go @@ -256,7 +256,10 @@ func (s *state) claimSlug(ctx context.Context, b *bot.Bot, msg *models.Message, // the old one — "/newpack mypack New Title" answering "Created Old." resumed := existing resumed.Title = intent.Title - resumed.Name = intent.Name + // 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: diff --git a/internal/modules/sticker/pack_handlers_test.go b/internal/modules/sticker/pack_handlers_test.go index 08d782f..eaa63bc 100644 --- a/internal/modules/sticker/pack_handlers_test.go +++ b/internal/modules/sticker/pack_handlers_test.go @@ -910,3 +910,34 @@ func TestNewPack_ResumeUsesTheTitleJustTyped(t *testing.T) { } } } + +// 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/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. From 20c6913f7c6da5ea2e461cc51718184d1eb564e6 Mon Sep 17 00:00:00 2001 From: tiennm99 <tiennm99@outlook.com> Date: Tue, 25 Aug 2026 17:19:04 +0700 Subject: [PATCH 11/11] docs(plans): add round 6-7 verification reports --- .../verify-260825-1720-sticker-round7.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 plans/reports/verify-260825-1720-sticker-round7.md 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.