From 3751010b8ef8d8be90b7b9acef4b859099b1369a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 25 Aug 2026 15:54:28 +0700 Subject: [PATCH] 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 `, `/stats cmd ` | | `monkeyd` | `/monkeyd_crawl [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags ` list its tags as hashtags | +| `sticker` | One personal sticker pack per user: `/newpack`, `/mypack`, `/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`, `/renamepack`, `/delpack`. See [docs/sticker-packs.md](docs/sticker-packs.md) | Disable modules with the `MODULES` environment variable. diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 38e6318..a8d20f1 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -72,6 +72,15 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "monkeyd_crawl": " [font_size]", "monkeyd_tags": "", "random": "", + "newpack": " ", + "mypack": "", + "addsticker": "[emoji...]", + "delsticker": "", + "editsticker": "", + "ordersticker": "", + "setpackicon": "", + "renamepack": "", + "delpack": "", "stats": "[users | user | cmd ]", "stock_events": " [days]", "stock_info": "", @@ -91,7 +100,11 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { if len(menu) != len(reg.PublicCommands()) { t.Fatalf("menu commands = %d, public commands = %d", len(menu), len(reg.PublicCommands())) } + // This map lists the commands whose parameter strings are pinned; commands + // absent from it are not asserted (the lookup yields "" for them). + seen := map[string]bool{} for _, command := range reg.PublicCommands() { + seen[command.Name] = true if got := command.Parameters; got != expectedParameters[command.Name] { t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name]) } @@ -107,6 +120,20 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { } } + // Every expectation must correspond to a registered command. + // + // Without this, an entry whose command stopped being registered simply + // stopped being checked: the forward loop only visits commands that exist, + // so removing a whole module from factories() left the suite green. That is + // also what makes the parameterless entries above load-bearing rather than + // decorative — "" == "" asserts nothing on its own, but the command having + // to exist at all does. + for name := range expectedParameters { + if !seen[name] { + t.Errorf("/%s is expected but not registered — a module dropped out of factories()", name) + } + } + help := moduleutil.RenderHelp(reg) if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest { t.Fatalf("/help source is %d characters, exceeds conservative Telegram limit %d", utf8.RuneCountInString(help), telegramMessageMaxRunesForTest) diff --git a/cmd/server/main.go b/cmd/server/main.go index 7acf76e..fe3b13a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,6 +26,7 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/misc" "github.com/tiennm99/miti99bot/internal/modules/monkeyd" "github.com/tiennm99/miti99bot/internal/modules/stats" + "github.com/tiennm99/miti99bot/internal/modules/sticker" "github.com/tiennm99/miti99bot/internal/modules/stock" "github.com/tiennm99/miti99bot/internal/modules/util" "github.com/tiennm99/miti99bot/internal/modules/wordle" @@ -91,6 +92,7 @@ func factories() map[string]modules.Factory { "gold": gold.New, stock.CollectionName: stock.New, "stats": stats.New, + "sticker": sticker.New, } } diff --git a/docs/command-parameter-conventions.md b/docs/command-parameter-conventions.md index 61a79e9..897892f 100644 --- a/docs/command-parameter-conventions.md +++ b/docs/command-parameter-conventions.md @@ -15,6 +15,7 @@ remain responsible for parsing and validation. |---|---|---| | Required value | `` | `` | | Required comma-separated values | `` | `` | +| Required remaining text | `` | `` | | Optional value | `[name]` | `[date]` | | Optional remaining text | `[name...]` | `[target...]` | | Alternatives in an optional group | `[literal | literal ]` | `[users | user ]` | @@ -40,6 +41,7 @@ language or extra punctuation without a user-facing need. ```text /stock_buy +/renamepack /lol [date] /trongtruonghop [target...] /stats [users | user | cmd ] diff --git a/docs/sticker-packs.md b/docs/sticker-packs.md new file mode 100644 index 0000000..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` | ` ` | sticker or photo | Creates your pack and returns its share link | +| `/mypack` | — | — | Shows your pack: name, title, sticker count, link | +| `/addsticker` | `[emoji...]` | sticker or photo | Adds it to your pack | +| `/delsticker` | — | a sticker in your pack | Removes it | +| `/editsticker` | `` | a sticker in your pack | Replaces that sticker's emoji | +| `/ordersticker` | `` | a sticker in your pack | Moves it; positions start at 0 | +| `/setpackicon` | — | a sticker in your pack | Uses it as the pack icon | +| `/renamepack` | `` | — | Changes the displayed title | +| `/delpack` | — | — | Deletes the pack, after an inline confirmation | + +Only `/newpack` names a pack. Every other command resolves your single pack from +storage, or from the replied sticker's set. + +## The pack name is permanent + +`/newpack mypack My Pack` creates `t.me/addstickers/mypack_by_`. + +**Telegram has no method to rename a sticker set's short name.** That link is +fixed for the life of the pack. `/renamepack` changes only the displayed title. + +The only way to a different link is `/delpack` followed by `/newpack` under a new +name — and the stickers do not come along. `/delpack`'s confirmation states the +title, the number of stickers it destroys, the exact link being surrendered, and +that both are permanent, because that prompt is the last point at which someone +wanting "a rename" learns what it actually costs. + +Pack-name rules: 3–40 characters, lowercase letters, digits and underscores, +starting with a letter, no two underscores in a row, no trailing underscore. + +## Images + +Static stickers only. Animated, video, mask, and custom-emoji stickers are +rejected. + +- A replied **sticker** is added directly. +- A replied **photo** or image **document** (`image/png`, `image/jpeg`, + `image/webp`) is downloaded, resized so its long edge is exactly 512px with + the aspect ratio preserved, and uploaded as a PNG. +- Pack icons are resized to exactly 100×100, padded transparently. +- Sources above 2 MB, or with either side above 4096px, are rejected. + +Telegram allows 120 stickers per pack and 1–20 emoji per sticker. There is no +documented file-size limit for static stickers; the module applies its own +client-side ceiling and never presents it as a Telegram rule. + +## Who can use it + +Public — every user manages their own pack. + +**Anonymous group admins are refused.** Telegram substitutes a single global +`GroupAnonymousBot` user for every anonymous admin message, so without this +refusal all anonymous admins across all groups would share one pack. Turn off +anonymous posting for the message and try again. + +## Deliberate omissions + +- **Usage statistics** (`/stats`, `/top`, `/packstats`, …) — the Bot API does + not expose sticker usage counts, and `/stats` belongs to the `stats` module. +- **Animated, video, emoji, and mask packs** — out of scope; this module is + static-only. +- **More than one pack per user** — a deliberate simplification. It is what lets + every command but `/newpack` drop its pack argument. +- **`/cancel`** — meaningless without conversation state. +- **A `/repack` migration command** — copying a full pack is up to ~121 + sequential API calls, which would stall the bot for every user. + +## Behaviour worth knowing + +**Sticker counts are advisory.** `/mypack` reads the count from storage and +makes no API calls at all. Editing your pack through @Stickers changes the real +count without the bot seeing it; the number re-syncs whenever a command already +has a fresh view of the set. + +**Pack names are claimed first-come and held permanently.** The bot records who +claimed each name before it creates anything on Telegram, and only that user can +ever manage a pack under it. This is what stops someone from reading a pack's +name off its public link and taking it over. + +`/newpack` therefore reports when a name is taken, which reveals that some user +of this bot holds it. That is accepted: `t.me/addstickers/_by_` is +publicly probeable without the bot, so the command discloses nothing new. It +never says *who* holds a name. Refusals about *managing* a pack are deliberately +uniform for the opposite reason — see below. + +A name is claimed *before* the bot calls Telegram, not after the pack exists. +That ordering is 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/", and every transport +// failure from http.Client.Do is a *url.Error whose Error() embeds the full +// URL — which the dispatcher then logs verbatim. A mid-transfer timeout, which +// is trivially reachable, would print the bot token to stdout and every log +// shipper downstream. +var errDownloadFailed = errors.New("sticker: download failed") + +// downloadClient is separate from the library's so its timeout is ours. +var downloadClient = &http.Client{Timeout: downloadTimeout} + +// downloadFile fetches a Telegram file by ID, bounded in bytes and time. +// +// The original error is discarded rather than wrapped: wrapping would keep the +// URL reachable through errors.Unwrap and %v, which defeats the point. +func downloadFile(ctx context.Context, b *bot.Bot, fileID string) ([]byte, error) { + f, err := b.GetFile(ctx, &bot.GetFileParams{FileID: fileID}) + if err != nil { + log.Error("sticker_getfile", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + if f.FileSize > maxSourceBytes { + return nil, refuse(fmt.Sprintf("That image is too large — keep it under %d MB.", maxSourceBytes>>20)) + } + + link := b.FileDownloadLink(f) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil) + if err != nil { + log.Error("sticker_download_request", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + resp, err := downloadClient.Do(req) + if err != nil { + log.Error("sticker_download", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + log.Error("sticker_download", "file_id", fileID, "reason", "status", "status", resp.StatusCode) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + + // Never trust Content-Length; bound the reader itself. One extra byte is + // read so an oversized body is detected rather than silently truncated. + data, err := io.ReadAll(io.LimitReader(resp.Body, maxSourceBytes+1)) + if err != nil { + log.Error("sticker_download_read", "file_id", fileID, "reason", classify(err)) + return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed) + } + if len(data) > maxSourceBytes { + return nil, refuse(fmt.Sprintf("That image is too large — keep it under %d MB.", maxSourceBytes>>20)) + } + return data, nil +} + +// classify reduces an error to a coarse label that cannot contain a URL. +// +// It deliberately inspects only the error's *type*, never its text: any path +// that formats the original error risks carrying the token along with it. +func classify(err error) string { + var urlErr *url.Error + switch { + case err == nil: + return "none" + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case errors.Is(err, context.Canceled): + return "cancelled" + case errors.As(err, &urlErr): + if urlErr.Timeout() { + return "timeout" + } + return "transport" + } + return "unknown" +} diff --git a/internal/modules/sticker/download_test.go b/internal/modules/sticker/download_test.go new file mode 100644 index 0000000..9bb7db2 --- /dev/null +++ b/internal/modules/sticker/download_test.go @@ -0,0 +1,157 @@ +package sticker + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-telegram/bot" +) + +// The download URL is "https://api.telegram.org/file/bot/", and +// every transport failure from http.Client.Do is a *url.Error whose Error() +// embeds it in full. The dispatcher logs a handler's returned error verbatim, +// so an error that carried the URL would print the bot token to stdout and +// every log shipper downstream. +// +// This asserts the property directly rather than trusting the discipline: a +// forced transport failure must produce an error that contains neither the +// token, nor "bot", nor any part of the URL. +func TestDownloadFile_ErrorNeverLeaksTokenOrURL(t *testing.T) { + const token = "123456:SUPER-SECRET-BOT-TOKEN" + + // A server that accepts getFile, then hangs up mid-download. + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/getFile") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/file_1.jpg"}}`)) + return + } + // The file fetch: close the connection without a response. + hj, ok := w.(http.Hijacker) + if !ok { + srv.CloseClientConnections() + return + } + conn, _, err := hj.Hijack() + if err == nil { + _ = conn.Close() + } + })) + defer srv.Close() + + b, err := bot.New(token, bot.WithSkipGetMe(), bot.WithServerURL(srv.URL)) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + + _, err = downloadFile(context.Background(), b, "f1") + if err == nil { + t.Fatal("downloadFile succeeded against a hung-up server; want an error") + } + if !errors.Is(err, errDownloadFailed) { + t.Errorf("err = %v, want it to be errDownloadFailed", err) + } + + text := err.Error() + for _, forbidden := range []string{token, "SUPER-SECRET", "bot", "http", srv.URL} { + if strings.Contains(text, forbidden) { + t.Errorf("error text %q contains %q — the token or URL can reach the logs", text, forbidden) + } + } + // Unwrapping must not reach the original either: %v on a wrapped *url.Error + // would put the URL back. + if inner := errors.Unwrap(err); inner != nil && strings.Contains(inner.Error(), token) { + t.Errorf("unwrapped error %q still carries the token", inner) + } +} + +// classify must reduce an error to a fixed label. It inspects only the error's +// type, never its text, so there is no path by which a URL can ride along. +func TestClassify_ReturnsFixedLabels(t *testing.T) { + allowed := map[string]bool{"none": true, "timeout": true, "cancelled": true, "transport": true, "unknown": true} + cases := []error{ + nil, + context.DeadlineExceeded, + context.Canceled, + errors.New("https://api.telegram.org/file/bot123:SECRET/x.jpg refused"), + } + for _, err := range cases { + got := classify(err) + if !allowed[got] { + t.Errorf("classify(%v) = %q, which is not one of the fixed labels", err, got) + } + if strings.Contains(got, "SECRET") || strings.Contains(got, "http") { + t.Errorf("classify leaked error text: %q", got) + } + } +} + +// The size guard runs on the metadata getFile returns, so an oversized file +// costs zero bytes of transfer. +func TestDownloadFile_RejectsOversizedBeforeFetching(t *testing.T) { + var fetches int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/getFile") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":99999999,"file_path":"photos/huge.jpg"}}`)) + return + } + fetches++ + _, _ = w.Write([]byte("should never be fetched")) + })) + defer srv.Close() + + b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL)) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + + if _, err := downloadFile(context.Background(), b, "f1"); err == nil { + t.Fatal("downloadFile accepted an oversized file") + } + if fetches != 0 { + t.Errorf("made %d HTTP fetches for an oversized file, want 0", fetches) + } +} + +// Content-Length is attacker-controlled; the reader itself is what bounds the +// transfer. +func TestDownloadFile_BoundsBodyRegardlessOfContentLength(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/getFile") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/lie.jpg"}}`)) + return + } + // Claims to be tiny, sends far more. + w.Header().Set("Content-Length", "10") + w.Header().Set("Content-Type", "image/jpeg") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + chunk := make([]byte, 64<<10) + for written := 0; written < maxSourceBytes+(128<<10); written += len(chunk) { + if _, err := w.Write(chunk); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + } + })) + defer srv.Close() + + b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL)) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + + data, err := downloadFile(context.Background(), b, "f1") + if err == nil { + t.Fatalf("downloadFile accepted %d bytes despite the %d-byte cap", len(data), maxSourceBytes) + } +} diff --git a/internal/modules/sticker/emoji.go b/internal/modules/sticker/emoji.go new file mode 100644 index 0000000..bdf00e1 --- /dev/null +++ b/internal/modules/sticker/emoji.go @@ -0,0 +1,209 @@ +package sticker + +import ( + "fmt" + "strings" + "unicode" +) + +const ( + // defaultEmoji is used when a sticker is added with no emoji given and the + // source carries none. Telegram requires at least one. + defaultEmoji = "⭐" + // maxEmojiPerSticker mirrors the documented emoji_list range of 1-20. + maxEmojiPerSticker = 20 +) + +// Code points that bind to the cluster before them rather than starting one. +// Written as hex because the literals are invisible in source. +const ( + zwj = rune(0x200D) // zero-width joiner: 👩‍👩‍👧 is one emoji + variationSelector16 = rune(0xFE0F) // forces emoji presentation + variationSelector15 = rune(0xFE0E) // forces text presentation + keycapCombining = rune(0x20E3) // the enclosing box of 1️⃣ + tagLow = rune(0xE0020) // tag block: 🏴 + tags spells a subdivision flag + tagHigh = rune(0xE007F) +) + +// parseEmoji splits an emoji argument run into individual emoji, accepting both +// "😂 🔥" and "😂🔥". +// +// Go has no stdlib grapheme segmentation, and a dependency for this one job is +// disproportionate — so this hand-rolls the subset of UAX #29 that emoji need: +// ZWJ sequences, variation selectors, skin-tone modifiers, regional-indicator +// pairs, and keycaps. A sequence that splits wrongly is a test case to add, not +// a redesign. +// +// Because no sticker command takes a pack argument any more, every argument +// reaching here is meant to be an emoji: a stray word fails loudly rather than +// being silently reinterpreted. +func parseEmoji(args []string) ([]string, error) { + joined := strings.Join(args, "") + joined = strings.TrimSpace(joined) + if joined == "" { + return nil, nil + } + + var out []string + for _, cluster := range splitClusters(joined) { + if isSpace(cluster) { + continue + } + if !isEmojiCluster(cluster) { + return nil, refuse(fmt.Sprintf("%q is not an emoji. Give one or more emoji, like 😂🔥.", cluster)) + } + out = append(out, cluster) + } + if len(out) > maxEmojiPerSticker { + return nil, refuse(fmt.Sprintf("At most %d emoji per sticker.", maxEmojiPerSticker)) + } + return out, nil +} + +// splitClusters breaks s into emoji-aware clusters. +func splitClusters(s string) []string { + runes := []rune(s) + var out []string + for i := 0; i < len(runes); { + start := i + i++ + // A regional indicator pairs with a following one to form a flag. + if isRegionalIndicator(runes[start]) && i < len(runes) && isRegionalIndicator(runes[i]) { + i++ + out = append(out, string(runes[start:i])) + continue + } + // Absorb everything that binds leftward: modifiers, variation + // selectors, combining marks, keycaps, and ZWJ-joined continuations. + for i < len(runes) { + r := runes[i] + switch { + case r == zwj: + // Absorb the joiner and its continuation only when what follows + // can actually continue an emoji sequence. Taking the next rune + // unconditionally let "😀🇻🇳" swallow the flag's first + // half and emit the orphaned second half as its own cluster — + // two invalid emoji, both of which passed validation. + if i+1 < len(runes) && isEmojiRune(runes[i+1]) && !isRegionalIndicator(runes[i+1]) { + i += 2 + continue + } + // A dangling joiner. Absorb it so it cannot start a cluster of + // its own; trimJoiners drops it from the emitted cluster. + i++ + goto done + case isBinding(r): + i++ + default: + goto done + } + } + done: + // A cluster ending in a joiner is incomplete. "😀" is not an emoji + // and Telegram rejects it, but it used to pass validation because the + // cluster's first rune looked fine. + if cluster := trimJoiners(string(runes[start:i])); cluster != "" { + out = append(out, cluster) + } + } + return out +} + +// trimJoiners strips leading and trailing zero-width joiners from a cluster. +func trimJoiners(cluster string) string { + return strings.Trim(cluster, string(zwj)) +} + +// isBinding reports whether r attaches to the preceding cluster. +func isBinding(r rune) bool { + switch { + case r == variationSelector16, r == variationSelector15, r == keycapCombining: + return true + case r >= tagLow && r <= tagHigh: + // Subdivision flags (🏴 + "gbeng" + terminator) are one emoji. Without + // this the base flag split from its tags and the tags, being invisible, + // produced a refusal quoting characters the user could not see. + return true + case isSkinTone(r): + return true + case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Me, r): + return true + } + return false +} + +func isSkinTone(r rune) bool { return r >= 0x1F3FB && r <= 0x1F3FF } +func isRegionalIndicator(r rune) bool { return r >= 0x1F1E6 && r <= 0x1F1FF } + +func isSpace(cluster string) bool { return strings.TrimSpace(cluster) == "" } + +// isEmojiCluster reports whether a cluster is emoji rather than ordinary text. +// +// The test is on the cluster's first rune, since that is what determines the +// cluster's identity — the rest is bound modifiers. Keycaps are the exception: +// "1️⃣" starts with an ASCII digit, so a cluster carrying the keycap combining +// mark counts regardless of its base. +func isEmojiCluster(cluster string) bool { + runes := []rune(cluster) + if len(runes) == 0 { + return false + } + if strings.ContainsRune(cluster, keycapCombining) { + return true + } + if isRegionalIndicator(runes[0]) { + // A flag is exactly two regional indicators. An odd count leaves a lone + // one at the end, which is not an emoji — Telegram rejects it, and it + // used to pass because isEmojiRune accepts the block. + return len(runes) == 2 && isRegionalIndicator(runes[1]) + } + return isEmojiRune(runes[0]) +} + +// emojiRanges are the Unicode blocks Telegram's emoji actually come from. +// Deliberately ranges rather than a property lookup: Go's unicode package +// exposes no Emoji property, and the blocks are stable. +var emojiRanges = [...]struct{ lo, hi rune }{ + {0x1F300, 0x1FAFF}, // pictographs, emoticons, transport, symbols, extended-A + {0x1F000, 0x1F2FF}, // mahjong, dominoes, playing cards, enclosed + {0x2600, 0x27BF}, // misc symbols + dingbats + {0x2B00, 0x2BFF}, // arrows and stars (⭐ lives here) + {0x2190, 0x21FF}, // arrows + {0x2300, 0x23FF}, // misc technical (⌚, ⏰) + {0x25A0, 0x25FF}, // geometric shapes + {0x1F1E6, 0x1F1FF}, // regional indicators; isEmojiCluster requires a pair +} + +// emojiSingletons are emoji stranded between the blocks above. +// +// Listed one by one rather than by widening a range, because their neighbours +// are not emoji: Ⓜ sits in enclosed alphanumerics next to circled digits, and © +// and ® sit in Latin-1 next to ordinary punctuation. Widening to cover them +// would start accepting text as emoji, which Telegram then rejects. +var emojiSingletons = map[rune]bool{ + 0x203C: true, // ‼ + 0x2049: true, // ⁉ + 0x2122: true, // ™ + 0x2139: true, // ℹ + 0x00A9: true, // © + 0x00AE: true, // ® + 0x2934: true, // ⤴ + 0x2935: true, // ⤵ + 0x24C2: true, // Ⓜ + 0x3030: true, // 〰 + 0x303D: true, // 〽 + 0x3297: true, // ㊗ + 0x3299: true, // ㊙ +} + +func isEmojiRune(r rune) bool { + if emojiSingletons[r] { + return true + } + for _, block := range emojiRanges { + if r >= block.lo && r <= block.hi { + return true + } + } + return false +} diff --git a/internal/modules/sticker/emoji_test.go b/internal/modules/sticker/emoji_test.go new file mode 100644 index 0000000..8d1b1b7 --- /dev/null +++ b/internal/modules/sticker/emoji_test.go @@ -0,0 +1,154 @@ +package sticker + +import ( + "strings" + "testing" +) + +func TestParseEmoji(t *testing.T) { + cases := []struct { + name string + args []string + want []string + }{ + {"single", []string{"😂"}, []string{"😂"}}, + {"space separated", []string{"😂", "🔥"}, []string{"😂", "🔥"}}, + {"joined in one arg", []string{"😂🔥"}, []string{"😂", "🔥"}}, + {"zwj family stays one", []string{"👩\u200d👩\u200d👧"}, []string{"👩\u200d👩\u200d👧"}}, + {"skin tone binds", []string{"👍🏽"}, []string{"👍🏽"}}, + {"flag is one cluster", []string{"🇻🇳"}, []string{"🇻🇳"}}, + {"two flags", []string{"🇻🇳🇯🇵"}, []string{"🇻🇳", "🇯🇵"}}, + {"keycap stays one", []string{"1️⃣"}, []string{"1️⃣"}}, + {"variation selector binds", []string{"❤️"}, []string{"❤️"}}, + {"star default", []string{"⭐"}, []string{"⭐"}}, + {"empty", nil, nil}, + {"blank", []string{" "}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseEmoji(tc.args) + if err != nil { + t.Fatalf("parseEmoji(%q) error: %v", tc.args, err) + } + if len(got) != len(tc.want) { + t.Fatalf("parseEmoji(%q) = %q, want %q", tc.args, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("cluster %d = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + +// A stray word must fail loudly. With no pack argument left on /addsticker, +// there is nothing else an argument could have meant. +func TestParseEmoji_RejectsPlainText(t *testing.T) { + for _, arg := range []string{"mypack", "hello 😂", "a"} { + if got, err := parseEmoji([]string{arg}); err == nil { + t.Errorf("parseEmoji(%q) = %q, want an error", arg, got) + } + } +} + +func TestParseEmoji_CapsAtTwenty(t *testing.T) { + twenty := strings.Repeat("😂", maxEmojiPerSticker) + if got, err := parseEmoji([]string{twenty}); err != nil { + t.Fatalf("parseEmoji(20 emoji) error: %v (got %d)", err, len(got)) + } + if _, err := parseEmoji([]string{twenty + "🔥"}); err == nil { + t.Error("parseEmoji(21 emoji) succeeded, want an error") + } +} + +// Refusals are shown to the user verbatim, so they must be userError. +func TestParseEmoji_RefusalIsUserFacing(t *testing.T) { + _, err := parseEmoji([]string{"notanemoji"}) + if err == nil { + t.Fatal("want an error") + } + if _, ok := err.(userError); !ok { + t.Errorf("err is %T, want userError so the handler can echo it", err) + } +} + +// TestParseEmoji_ClusterEdgeCases pins the emoji-clustering rules that a +// hand-rolled segmenter gets wrong. Every case here failed before the +// clustering fix: the first group was refused outright, the second silently +// produced an emoji_list Telegram rejects. +func TestParseEmoji_ClusterEdgeCases(t *testing.T) { + accepted := []struct { + name string + in string + want []string + }{ + // Emoji that fall between the blocks the range table covers. + {"copyright", "©️", []string{"©️"}}, + {"registered", "®️", []string{"®️"}}, + {"wavy dash", "〰️", []string{"〰️"}}, + {"part alternation", "〽️", []string{"〽️"}}, + {"japanese congratulations", "㊗️", []string{"㊗️"}}, + {"japanese secret", "㊙️", []string{"㊙️"}}, + {"circled m", "Ⓜ️", []string{"Ⓜ️"}}, + {"arrow curving up", "⤴️", []string{"⤴️"}}, + {"arrow curving down", "⤵️", []string{"⤵️"}}, + + // A subdivision flag is a base flag plus invisible tag characters. + { + "tag sequence flag", + "\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F", + []string{"\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F"}, + }, + + // A dangling joiner is dropped rather than shipped. + {"trailing joiner", "\U0001F600\u200d", []string{"\U0001F600"}}, + + // The joiner must not swallow a flag's first half. + { + "joiner before a flag", + "\U0001F600\u200d\U0001F1FB\U0001F1F3", + []string{"\U0001F600", "\U0001F1FB\U0001F1F3"}, + }, + + // Sequences that already worked, kept here so a fix cannot regress them. + {"family", "\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466", []string{"\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466"}}, + {"skin tone", "\U0001F44D\U0001F3FD", []string{"\U0001F44D\U0001F3FD"}}, + {"keycap", "1️⃣", []string{"1️⃣"}}, + {"flag", "\U0001F1FB\U0001F1F3", []string{"\U0001F1FB\U0001F1F3"}}, + } + for _, tc := range accepted { + t.Run(tc.name, func(t *testing.T) { + got, err := parseEmoji([]string{tc.in}) + if err != nil { + t.Fatalf("parseEmoji(%+q) refused: %v", tc.in, err) + } + if len(got) != len(tc.want) { + t.Fatalf("parseEmoji(%+q) = %+q, want %+q", tc.in, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("parseEmoji(%+q) = %+q, want %+q", tc.in, got, tc.want) + } + } + }) + } + + refused := []struct { + name string + in string + }{ + // A lone regional indicator is half a flag, and Telegram rejects it. + {"lone regional indicator", "\U0001F1FB"}, + {"odd regional indicator count", "\U0001F1FB\U0001F1F3\U0001F1FA"}, + {"plain text", "hello"}, + } + for _, tc := range refused { + t.Run(tc.name, func(t *testing.T) { + got, err := parseEmoji([]string{tc.in}) + if err == nil { + t.Fatalf("parseEmoji(%+q) = %+q, want refusal", tc.in, got) + } + }) + } +} diff --git a/internal/modules/sticker/errors.go b/internal/modules/sticker/errors.go new file mode 100644 index 0000000..ba3e510 --- /dev/null +++ b/internal/modules/sticker/errors.go @@ -0,0 +1,123 @@ +package sticker + +import ( + "context" + "errors" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" +) + +// userError carries text meant to be shown to the user verbatim. +// +// The module has two kinds of failure and must never confuse them: a refusal +// the user can act on ("that pack name is too long"), and an internal failure +// that must not reach a reply at all — plan rule 5 exists because a transport +// error's text can embed the bot token. Wrapping the first kind in a distinct +// type makes "is this safe to echo?" a type question instead of a judgement +// call at each call site. +type userError struct{ msg string } + +func (e userError) Error() string { return e.msg } + +// refuse builds a userError. Its text is replied verbatim, so write it as a +// sentence addressed to the user. +func refuse(msg string) error { return userError{msg: msg} } + +// errNoUsername means the bot's own username is unavailable, so no new set name +// can be built. Internal, not user-facing: nothing the caller does fixes it. +var errNoUsername = errors.New("sticker: bot has no username") + +// isStickerSetMissing reports whether err positively says the set does not +// exist on Telegram's side. +// +// Classification here is positive-only, and deliberately so: this is the one +// signal that authorises deleting a user's pack record. A network blip, a 429, +// or a context cancelled by SIGTERM must never be read as "the pack is gone" — +// under one pack per user that would destroy the only record of a live pack and +// block /newpack until the phantom cleared. +func isStickerSetMissing(err error) bool { + return errors.Is(err, bot.ErrorBadRequest) && + strings.Contains(err.Error(), "STICKERSET_INVALID") +} + +// apiRefusal maps a Telegram API error to user-facing text, or returns ok=false +// when the error has no specific meaning and should be treated as a failure. +// +// Matching is on MTProto code substrings rather than prose. The Bot API server +// rewrites only three of these into English (PACK_SHORT_NAME_OCCUPIED, +// PACK_SHORT_NAME_INVALID, STICKER_EMOJI_INVALID); the rest arrive as +// "Bad Request: ", and the prose for the three could change without +// notice. Both forms are matched where they differ. +func apiRefusal(err error) (string, bool) { + if err == nil { + return "", false + } + text := err.Error() + switch { + case contains(text, "PACK_SHORT_NAME_OCCUPIED", "already occupied"): + return "That pack name is taken. Pick a different one.", true + case contains(text, "PACK_SHORT_NAME_INVALID", "invalid sticker set name"): + return "Telegram rejected that pack name. Use lowercase letters, digits and single underscores.", true + case contains(text, "PACK_TITLE_INVALID"): + return "Telegram rejected that title. Try a shorter, simpler one.", true + case contains(text, "STICKERSET_INVALID"): + return "Your pack no longer exists on Telegram. Use /newpack to create a new one.", true + case contains(text, "STICKERS_TOO_MUCH"): + return "Your pack is full (120 stickers).", true + case contains(text, "STICKER_EMOJI_INVALID", "invalid sticker emojis"): + return "Telegram rejected those emoji. Try different ones.", true + case contains(text, "too many emoji specified"): + return "At most 20 emoji per sticker.", true + case contains(text, "STICKER_PNG_DIMENSIONS", "STICKER_DIMENSIONS_INVALID"): + return "Telegram rejected that image's dimensions.", true + } + return "", false +} + +// replyAPIError converts a Telegram API error into a reply. Errors with no +// specific mapping are logged and answered generically — the raw error never +// reaches the user. +func replyAPIError(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error { + if text, ok := apiRefusal(err); ok { + return reply(ctx, b, msg, text) + } + log.Error(op, "err", err) + return reply(ctx, b, msg, genericFailure) +} + +func contains(text string, needles ...string) bool { + for _, n := range needles { + if strings.Contains(text, n) { + return true + } + } + return false +} + +// createRefused reports whether err proves CreateNewStickerSet created nothing. +// +// Deliberately separate from apiRefusal even though today their code lists +// overlap. apiRefusal's job is "map an error to user-facing text"; this one's is +// "prove no set exists", which is what authorises releasing a name reservation +// and dropping a write-ahead intent. Reusing apiRefusal for both would mean the +// next person adding a code there for wording reasons silently converts it into +// a strand-the-slug bug. +// +// Every code here is a request-validation refusal: Telegram rejected the call +// before creating anything. +func createRefused(err error) bool { + if err == nil { + return false + } + return contains(err.Error(), + "PACK_SHORT_NAME_OCCUPIED", "already occupied", + "PACK_SHORT_NAME_INVALID", "invalid sticker set name", + "PACK_TITLE_INVALID", + "STICKER_EMOJI_INVALID", "invalid sticker emojis", + "too many emoji specified", + ) +} diff --git a/internal/modules/sticker/handlers_test.go b/internal/modules/sticker/handlers_test.go new file mode 100644 index 0000000..12ad720 --- /dev/null +++ b/internal/modules/sticker/handlers_test.go @@ -0,0 +1,440 @@ +package sticker + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +const ( + testUser = int64(42) + testChat = int64(1000) + testSet = "mypack_by_testbot" + otherSet = "someoneelse_by_testbot" +) + +var fixedNow = time.UnixMilli(1_700_000_000_000) + +// newTestState builds a state over one in-memory collection, matching +// production: both typed views share the collection, with disjoint key spaces. +func newTestState() *state { + coll := storage.NewMemoryProvider().Collection("sticker") + return &state{ + store: storage.Typed[Pack](coll), + pending: storage.Typed[PendingDelete](coll), + slugs: storage.Typed[SlugReservation](coll), + nowFn: func() time.Time { return fixedNow }, + } +} + +func seedPack(t *testing.T, s *state, count int) Pack { + t.Helper() + pack := Pack{ + Slug: "mypack", Name: testSet, Title: "My Pack", + OwnerID: testUser, Count: count, CreatedAt: fixedNow.UnixMilli(), + } + if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil { + t.Fatalf("seed pack: %v", err) + } + // A real pack always carries its name reservation; seeding without one + // would let tests pass against a state production cannot reach. + if err := s.slugs.Put(context.Background(), slugKey(pack.Slug), + SlugReservation{Slug: pack.Slug, OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil { + t.Fatalf("seed reservation: %v", err) + } + return pack +} + +// stickerReply builds a message replying to a sticker in setName. +func stickerReply(text, setName string) *models.Update { + upd := testutil.NewPrivateMessage(testUser, text) + upd.Message.Chat.ID = testChat + upd.Message.ReplyToMessage = &models.Message{ + Sticker: &models.Sticker{ + FileID: "file-in-" + setName, + FileUniqueID: "uniq", + Type: "regular", + SetName: setName, + Emoji: "🎉", + }, + } + return upd +} + +func loadPack(t *testing.T, s *state) (Pack, bool) { + t.Helper() + pack, found, err := getPack(context.Background(), s.store, testUser) + if err != nil { + t.Fatalf("load pack: %v", err) + } + return pack, found +} + +// methodsSent lists the API methods a run produced, so a test can assert both +// what was called and that nothing was. +func methodsSent(rb *testutil.RecordingBot) []string { + var out []string + for _, call := range rb.Sent() { + out = append(out, call.Method) + } + return out +} + +func countMethod(rb *testutil.RecordingBot, method string) int { + n := 0 + for _, call := range rb.Sent() { + if call.Method == method { + n++ + } + } + return n +} + +func TestAddSticker_HappyPath(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + + if countMethod(rb, "addStickerToSet") != 1 { + t.Fatalf("methods = %v, want one addStickerToSet", methodsSent(rb)) + } + for _, call := range rb.Sent() { + if call.Method != "addStickerToSet" { + continue + } + if call.Form["name"] != testSet { + t.Errorf("name = %q, want %q", call.Form["name"], testSet) + } + // UserID is always the caller: a non-owner never reaches this call. + if call.Form["user_id"] != "42" { + t.Errorf("user_id = %q, want 42", call.Form["user_id"]) + } + if !strings.Contains(call.Form["sticker"], "😂") { + t.Errorf("sticker payload %q missing the explicit emoji", call.Form["sticker"]) + } + } + + pack, _ := loadPack(t, s) + if pack.Count != 4 { + t.Errorf("Count = %d, want 4", pack.Count) + } +} + +// Explicit args beat the replied sticker's emoji, which beats the default. +// addedStickerPayload returns the payload of the one addStickerToSet call the +// handler is expected to have made. +// +// Ranging over rb.Sent() and asserting only inside an `if call.Method == ...` +// makes the assertion vacuous: a handler that returns early and never calls +// Telegram at all satisfies it, because the loop body never runs. Requiring +// exactly one call is what makes these tests fail when the call disappears. +func addedStickerPayload(t *testing.T, rb *testutil.RecordingBot) string { + t.Helper() + var payloads []string + for _, call := range rb.Sent() { + if call.Method == "addStickerToSet" { + payloads = append(payloads, call.Form["sticker"]) + } + } + if len(payloads) != 1 { + t.Fatalf("addStickerToSet calls = %d, want exactly 1", len(payloads)) + } + return payloads[0] +} + +func TestAddSticker_EmojiPrecedence(t *testing.T) { + cases := []struct { + name string + text string + want string + }{ + {"explicit wins", "/addsticker 🔥", "🔥"}, + {"inherits from replied sticker", "/addsticker", "🎉"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 0) + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply(tc.text, otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if payload := addedStickerPayload(t, rb); !strings.Contains(payload, tc.want) { + t.Errorf("sticker payload %q, want emoji %q", payload, tc.want) + } + }) + } +} + +// With no emoji anywhere, the default keeps the call valid — Telegram rejects +// an empty emoji_list. +func TestAddSticker_FallsBackToDefaultEmoji(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 0) + + upd := stickerReply("/addsticker", otherSet) + upd.Message.ReplyToMessage.Sticker.Emoji = "" + if err := s.handleAddSticker(context.Background(), rb.Bot, upd); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if payload := addedStickerPayload(t, rb); !strings.Contains(payload, defaultEmoji) { + t.Errorf("sticker payload %q, want the default emoji", payload) + } +} + +// A stray word is caught by parseEmoji. With no pack argument left, there is +// nothing else it could have been mistaken for. +func TestAddSticker_RejectsNonEmojiArgument(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 0) + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker mypack", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if countMethod(rb, "addStickerToSet") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } + if !strings.Contains(rb.LastSent().Text(), "not an emoji") { + t.Errorf("reply = %q, want an emoji usage error", rb.LastSent().Text()) + } +} + +func TestAddSticker_NoPackYet(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if countMethod(rb, "addStickerToSet") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } + if !strings.Contains(rb.LastSent().Text(), "/newpack") { + t.Errorf("reply = %q, want it to point at /newpack", rb.LastSent().Text()) + } +} + +// A pending record means an unfinished /newpack: there is no usable pack yet, +// and the reply has to say how to finish it. +func TestAddSticker_PendingPackIsNotUsable(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + pack := seedPack(t, s, 0) + pack.Pending = true + if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil { + t.Fatalf("seed pending: %v", err) + } + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if countMethod(rb, "addStickerToSet") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } + if !strings.Contains(rb.LastSent().Text(), "incomplete") { + t.Errorf("reply = %q, want the incomplete-pack hint", rb.LastSent().Text()) + } +} + +func TestAddSticker_FullPack(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERS_TOO_MUCH") + s := newTestState() + seedPack(t, s, maxStickersPerPack) + + if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if !strings.Contains(rb.LastSent().Text(), "full") { + t.Errorf("reply = %q, want the pack-is-full message", rb.LastSent().Text()) + } + // A failed add must not move the count. + pack, _ := loadPack(t, s) + if pack.Count != maxStickersPerPack { + t.Errorf("Count = %d, want it unchanged at %d", pack.Count, maxStickersPerPack) + } +} + +func TestDelSticker_HappyPath(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 3) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + + if countMethod(rb, "deleteStickerFromSet") != 1 { + t.Fatalf("methods = %v, want exactly one deleteStickerFromSet", methodsSent(rb)) + } + pack, found := loadPack(t, s) + if !found { + t.Fatal("pack record deleted by a successful /delsticker") + } + if pack.Count != 2 { + t.Errorf("Count = %d, want 2", pack.Count) + } +} + +// R7: a transient failure must never destroy the record. The probe that would +// have done so was removed for exactly this reason. +func TestDelSticker_TransientErrorKeepsRecord(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethod("deleteStickerFromSet", 500, `{"ok":false,"description":"server exploded"}`) + s := newTestState() + seedPack(t, s, 3) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + pack, found := loadPack(t, s) + if !found { + t.Fatal("a transient error deleted the pack record") + } + if pack.Count != 3 { + t.Errorf("Count = %d, want it unchanged at 3", pack.Count) + } +} + +// The other half of the same rule: a *positive* STICKERSET_INVALID is the one +// signal that authorises dropping the record, and dropping it is what unblocks +// /newpack. +func TestDelSticker_SetGoneDropsRecord(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethodCode("deleteStickerFromSet", 400, "Bad Request: STICKERSET_INVALID") + s := newTestState() + seedPack(t, s, 1) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + if _, found := loadPack(t, s); found { + t.Error("record survived a positive STICKERSET_INVALID; /newpack stays blocked") + } +} + +// Deleting the last sticker may destroy the set Telegram-side, and /mypack +// makes no API calls so it cannot notice. The reply has to name the way out. +func TestDelSticker_EmptyPackNamesRecovery(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 1) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + text := rb.LastSent().Text() + if !strings.Contains(text, "/delpack") { + t.Errorf("reply = %q, want it to name /delpack", text) + } + pack, _ := loadPack(t, s) + if pack.Count != 0 { + t.Errorf("Count = %d, want 0", pack.Count) + } +} + +// Count is floored: a drifted record must not go negative. +func TestDelSticker_CountFlooredAtZero(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 0) + + if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil { + t.Fatalf("handleDelSticker: %v", err) + } + pack, _ := loadPack(t, s) + if pack.Count != 0 { + t.Errorf("Count = %d, want 0", pack.Count) + } +} + +func TestEditSticker_HappyPath(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 1) + + if err := s.handleEditSticker(context.Background(), rb.Bot, stickerReply("/editsticker 😂🔥", testSet)); err != nil { + t.Fatalf("handleEditSticker: %v", err) + } + if countMethod(rb, "setStickerEmojiList") != 1 { + t.Fatalf("methods = %v, want one setStickerEmojiList", methodsSent(rb)) + } +} + +// An empty emoji_list is invalid, so unlike /addsticker this cannot fall back +// to a default — the user has to say what they want. +func TestEditSticker_RequiresEmoji(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 1) + + if err := s.handleEditSticker(context.Background(), rb.Bot, stickerReply("/editsticker", testSet)); err != nil { + t.Fatalf("handleEditSticker: %v", err) + } + if countMethod(rb, "setStickerEmojiList") != 0 { + t.Errorf("methods = %v, want no API call", methodsSent(rb)) + } +} + +func TestOrderSticker(t *testing.T) { + cases := []struct { + name string + text string + wantAPI int + }{ + {"zero is valid", "/ordersticker 0", 1}, + // Not bounded locally: Telegram validates against the current set size + // and a local copy would go stale. + {"large position reaches the API", "/ordersticker 999", 1}, + {"negative rejected locally", "/ordersticker -1", 0}, + {"non-numeric rejected locally", "/ordersticker first", 0}, + {"missing argument rejected locally", "/ordersticker", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := testutil.NewRecordingBot(t) + s := newTestState() + seedPack(t, s, 5) + + if err := s.handleOrderSticker(context.Background(), rb.Bot, stickerReply(tc.text, testSet)); err != nil { + t.Fatalf("handleOrderSticker: %v", err) + } + if got := countMethod(rb, "setStickerPositionInSet"); got != tc.wantAPI { + t.Errorf("setStickerPositionInSet calls = %d, want %d (methods %v)", got, tc.wantAPI, methodsSent(rb)) + } + }) + } +} + +// The self-heal path frees the name too: STICKERSET_INVALID is a positive +// "this set is gone", so there is nothing left for the reservation to protect. +func TestSelfHeal_ReleasesTheName(t *testing.T) { + rb := testutil.NewRecordingBot(t) + rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERSET_INVALID") + s := newTestState() + seedPack(t, s, 2) + ctx := context.Background() + + if err := s.handleAddSticker(ctx, rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil { + t.Fatalf("handleAddSticker: %v", err) + } + if _, found := loadPack(t, s); found { + t.Error("pack record survived a positive STICKERSET_INVALID") + } + if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { + t.Error("the name is still reserved after the set was confirmed gone") + } +} diff --git a/internal/modules/sticker/image.go b/internal/modules/sticker/image.go new file mode 100644 index 0000000..28584eb --- /dev/null +++ b/internal/modules/sticker/image.go @@ -0,0 +1,162 @@ +package sticker + +import ( + "bytes" + "image" + "image/png" + + // Registered for their side effect: image.Decode needs the formats + // Telegram actually delivers. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" // read-only WEBP decoder + + "github.com/tiennm99/miti99bot/internal/log" +) + +const ( + // stickerEdge is Telegram's requirement: one side exactly 512px, the other + // at most 512px. + stickerEdge = 512 + // thumbnailEdge is the pack thumbnail requirement: exactly 100x100. + thumbnailEdge = 100 + + // maxDecodeDimension bounds peak allocation. Checked via DecodeConfig, + // before any pixel buffer exists. + // + // Note the 2 MB source cap bounds *compressed* bytes and so does not bound + // this: a flat-colour 4096x4096 PNG is a few tens of KB. At this cap a + // single conversion peaks near 90 MB live — the decoded source, the NRGBA + // scale target, and the PNG encode buffer coexist. Handlers run one at a + // time, so this is RSS pressure rather than a multiplier. + maxDecodeDimension = 4096 + + // softMaxStickerBytes is a *client-side* ceiling, not a documented limit. + // The widely-repeated 512 KB figure appears in no current official page, so + // it must never be stated to users as a rule. + softMaxStickerBytes = 512 << 10 +) + +// toStickerPNG converts an arbitrary image to a PNG sized for a static sticker: +// long edge exactly 512, aspect ratio preserved. +func toStickerPNG(src []byte) ([]byte, error) { + img, err := decodeBounded(src) + if err != nil { + return nil, err + } + + b := img.Bounds() + w, h := scaleToLongEdge(b.Dx(), b.Dy(), stickerEdge) + out := resize(img, w, h) + + data, err := encodePNG(out, png.DefaultCompression) + if err != nil { + return nil, err + } + if len(data) <= softMaxStickerBytes { + return data, nil + } + + // Try harder before losing resolution. + if data, err = encodePNG(out, png.BestCompression); err == nil && len(data) <= softMaxStickerBytes { + return data, nil + } + // Then step the long edge down. Below 320 the sticker is too small to be + // worth shrinking further; hand back the best effort instead. + // + // Each rung resamples the already-scaled 512px image, not the source. The + // source may be 4096x4096, and resampling it once per rung made the ladder + // cost four full-size resamples instead of one — seconds of uninterruptible + // CPU on a dispatcher that runs handlers one at a time, so a single user + // could stall the bot for everyone. Target dimensions still come from the + // original ratio, so the aspect is identical either way. + for _, edge := range []int{448, 384, 320} { + w, h = scaleToLongEdge(b.Dx(), b.Dy(), edge) + candidate, encErr := encodePNG(resize(out, w, h), png.BestCompression) + if encErr != nil { + return nil, encErr + } + data = candidate + if len(data) <= softMaxStickerBytes { + return data, nil + } + } + log.Error("sticker_image_oversized", "bytes", len(data)) + return data, nil +} + +// toThumbnailPNG converts an image to a pack thumbnail: exactly 100x100, with +// the short edge padded transparently so the aspect ratio survives. +func toThumbnailPNG(src []byte) ([]byte, error) { + img, err := decodeBounded(src) + if err != nil { + return nil, err + } + + b := img.Bounds() + w, h := scaleToLongEdge(b.Dx(), b.Dy(), thumbnailEdge) + scaled := resize(img, w, h) + + canvas := image.NewNRGBA(image.Rect(0, 0, thumbnailEdge, thumbnailEdge)) + offset := image.Pt((thumbnailEdge-w)/2, (thumbnailEdge-h)/2) + draw.Draw(canvas, scaled.Bounds().Add(offset), scaled, image.Point{}, draw.Src) + + return encodePNG(canvas, png.BestCompression) +} + +// decodeBounded reads the header first and refuses oversized images before any +// pixel buffer is allocated. +func decodeBounded(src []byte) (image.Image, error) { + cfg, _, err := image.DecodeConfig(bytes.NewReader(src)) + if err != nil { + return nil, refuse("That file is not an image Telegram can use. Send a PNG, JPEG or WEBP.") + } + if cfg.Width > maxDecodeDimension || cfg.Height > maxDecodeDimension { + return nil, refuse("That image is too big to process. Keep both sides under 4096 pixels.") + } + if cfg.Width <= 0 || cfg.Height <= 0 { + // Not "too large" — a header claiming no pixels at all. The user can + // act on this, so it must be a userError rather than surfacing as a + // generic failure with an ERROR log line. + return nil, refuse("That image has no usable dimensions. Send a normal PNG, JPEG or WEBP.") + } + img, _, err := image.Decode(bytes.NewReader(src)) + if err != nil { + return nil, refuse("That image could not be read. Send a PNG, JPEG or WEBP.") + } + return img, nil +} + +// scaleToLongEdge returns the dimensions that put the long edge exactly at +// edge, keeping the aspect ratio. The short edge is clamped to at least 1 so an +// extreme aspect ratio cannot produce a zero-dimension image. +func scaleToLongEdge(w, h, edge int) (int, int) { + if w <= 0 || h <= 0 { + return edge, edge + } + if w >= h { + short := int(float64(h)*float64(edge)/float64(w) + 0.5) + return edge, max(short, 1) + } + short := int(float64(w)*float64(edge)/float64(h) + 0.5) + return max(short, 1), edge +} + +// resize scales into a fresh NRGBA, which preserves alpha. +func resize(img image.Image, w, h int) *image.NRGBA { + out := image.NewNRGBA(image.Rect(0, 0, w, h)) + draw.CatmullRom.Scale(out, out.Bounds(), img, img.Bounds(), draw.Over, nil) + return out +} + +func encodePNG(img image.Image, level png.CompressionLevel) ([]byte, error) { + var buf bytes.Buffer + enc := png.Encoder{CompressionLevel: level} + if err := enc.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/modules/sticker/image_test.go b/internal/modules/sticker/image_test.go new file mode 100644 index 0000000..9fe578a --- /dev/null +++ b/internal/modules/sticker/image_test.go @@ -0,0 +1,156 @@ +package sticker + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" +) + +// makePNG generates a test image in-process, so no binary fixtures are +// committed and every case is readable from the test itself. +func makePNG(t *testing.T, w, h int, alpha uint8) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: alpha}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode fixture: %v", err) + } + return buf.Bytes() +} + +func decodeSize(t *testing.T, data []byte) (int, int) { + t.Helper() + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode result: %v", err) + } + return cfg.Width, cfg.Height +} + +// Telegram requires one side to be exactly 512 and the other at most 512. +func TestToStickerPNG_Geometry(t *testing.T) { + cases := []struct { + name string + w, h int + wantW, wantH int + }{ + {"landscape", 1024, 512, 512, 256}, + {"portrait", 300, 900, 171, 512}, + {"square", 512, 512, 512, 512}, + {"upscales small input", 64, 32, 512, 256}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := toStickerPNG(makePNG(t, tc.w, tc.h, 255)) + if err != nil { + t.Fatalf("toStickerPNG: %v", err) + } + gotW, gotH := decodeSize(t, out) + if gotW != tc.wantW || gotH != tc.wantH { + t.Errorf("%dx%d -> %dx%d, want %dx%d", tc.w, tc.h, gotW, gotH, tc.wantW, tc.wantH) + } + if gotW != stickerEdge && gotH != stickerEdge { + t.Errorf("neither side is exactly %d: %dx%d", stickerEdge, gotW, gotH) + } + }) + } +} + +// An extreme aspect ratio must not round the short edge down to zero, which +// would produce an invalid image rather than an error. +// +// 1x4000 rather than the plan's 1x5000: 5000 is past maxDecodeDimension, so +// that case never reaches the scaler at all — it is rejected by the guard +// below. The clamp still needs exercising, and this is the most extreme ratio +// that actually gets there (512/4000 rounds to 0 before clamping). +func TestToStickerPNG_ExtremeAspectRatio(t *testing.T) { + out, err := toStickerPNG(makePNG(t, 1, 4000, 255)) + if err != nil { + t.Fatalf("toStickerPNG: %v", err) + } + w, h := decodeSize(t, out) + if w < 1 || h < 1 { + t.Fatalf("got a zero-dimension image: %dx%d", w, h) + } + if h != stickerEdge { + t.Errorf("long edge = %d, want %d", h, stickerEdge) + } +} + +// The other half: past the cap, the guard refuses rather than the scaler +// coping. The bound exists to cap allocation, so it must win over any +// aspect-ratio handling. +func TestToStickerPNG_ExtremeAspectPastCapIsRejected(t *testing.T) { + if _, err := toStickerPNG(makePNG(t, 1, maxDecodeDimension+904, 255)); err == nil { + t.Fatal("an image past the dimension cap was accepted") + } +} + +// Stickers are cut-outs; losing alpha in the resize would put a black box +// behind every one of them. +func TestToStickerPNG_PreservesAlpha(t *testing.T) { + out, err := toStickerPNG(makePNG(t, 256, 256, 0)) + if err != nil { + t.Fatalf("toStickerPNG: %v", err) + } + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if _, _, _, a := img.At(128, 128).RGBA(); a != 0 { + t.Errorf("alpha = %d at a fully transparent pixel, want 0", a) + } +} + +func TestToThumbnailPNG_IsExactly100Square(t *testing.T) { + // A non-square input still has to come out exactly 100x100, padded. + out, err := toThumbnailPNG(makePNG(t, 800, 400, 255)) + if err != nil { + t.Fatalf("toThumbnailPNG: %v", err) + } + w, h := decodeSize(t, out) + if w != thumbnailEdge || h != thumbnailEdge { + t.Errorf("thumbnail = %dx%d, want %dx%d", w, h, thumbnailEdge, thumbnailEdge) + } +} + +// The dimension guard runs on the header, before any pixel buffer exists — it +// is what bounds peak allocation for attacker-supplied images. +func TestDecodeBounded_RejectsOversizedDimensions(t *testing.T) { + oversized := makePNG(t, maxDecodeDimension+1, 4, 255) + if _, err := toStickerPNG(oversized); err == nil { + t.Fatal("toStickerPNG accepted an image past the dimension cap") + } +} + +func TestDecodeBounded_RejectsNonImage(t *testing.T) { + if _, err := toStickerPNG([]byte("this is not an image")); err == nil { + t.Fatal("toStickerPNG accepted non-image bytes") + } +} + +func TestScaleToLongEdge(t *testing.T) { + cases := []struct { + w, h, edge int + wantW, wantH int + }{ + {1000, 500, 512, 512, 256}, + {500, 1000, 512, 256, 512}, + {100, 100, 512, 512, 512}, + {5000, 1, 512, 512, 1}, // clamped, never 0 + } + for _, tc := range cases { + gotW, gotH := scaleToLongEdge(tc.w, tc.h, tc.edge) + if gotW != tc.wantW || gotH != tc.wantH { + t.Errorf("scaleToLongEdge(%d, %d, %d) = (%d, %d), want (%d, %d)", + tc.w, tc.h, tc.edge, gotW, gotH, tc.wantW, tc.wantH) + } + } +} diff --git a/internal/modules/sticker/pack.go b/internal/modules/sticker/pack.go new file mode 100644 index 0000000..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 "_by_", and stays bot-manageable because the +// bot created it. +// +// One pack per user is the central simplification: no command except /newpack +// takes a pack argument, because there is only ever one pack to act on. +package sticker + +import ( + "context" + "errors" + "strconv" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +// Pack is the single bot-created sticker set owned by a Telegram user. +// +// The record is keyed by owner ID alone, which makes the lookup itself the +// ownership check: there is no way to read a pack without naming its owner. +type Pack struct { + Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL + Name string `bson:"name"` // Telegram set name, "_by_" + Title string `bson:"title"` // display title, mutable via /renamepack + OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to + Count int `bson:"count"` // stickers in the set; keeps /mypack API-free + Pending bool `bson:"pending"` // write-ahead intent; see newpack's state machine + CreatedAt int64 `bson:"createdAt"` // unix millis +} + +// PackStore is the module's view over its collection. +type PackStore = storage.DocStore[Pack] + +// SlugReservation records which user claimed a pack name, globally. +// +// Pack records are keyed by owner, so they can only answer "does *this* user +// have a pack" — they cannot answer "who holds this name". Without that second +// question the module cannot tell its own interrupted attempt from a set +// belonging to someone else, because both look identical from the caller's +// side: a pending record naming a set that exists. 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 \nEg: /newpack mypack My Pack" + // slugTaken answers a name held by anyone other than the caller. It says + // only "taken", never who holds it — the plan accepts that pack names are + // enumerable (share links are public), but there is no reason to confirm + // which are backed by real packs any more precisely than Telegram already + // does. + slugTaken = "That pack name is taken. Pick a different one." + noPackYet = "You don't have a pack yet. Reply to a sticker with /newpack to create one." + pendingMarker = "\n\n⚠️ This pack is incomplete — re-run the same /newpack command to finish it." +) + +// handleNewPack creates the caller's pack. +// +// The hard part is not the API call, it is surviving an interruption. The slug +// fixes a permanent public URL, so an attempt that dies between "Telegram +// created the set" and "we wrote it down" would strand that slug forever: the +// set exists, nobody's record points at it, and the user cannot recreate it. +// +// The fix is a write-ahead record, claimed before Telegram is called, in two +// parts that answer two different questions: +// +// - a global reservation on the name — "who claimed this name?" (reserveSlug) +// - an owner-keyed pending Pack — "does this user have a pack?" (claimSlug) +// +// Both are needed. The pending record alone proves only that this caller *asked +// for* the name, which a user naming someone else's pack also does; 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 \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_" suffix inside maxSetNameLen. + minSlugLen = 3 + maxSlugLen = 40 +) + +// slugRe is the user-chosen half of a set name: 3-40 chars, starting with a +// letter. Telegram additionally forbids consecutive underscores, which a +// character class cannot express, so validateSlug checks that separately. +var slugRe = regexp.MustCompile(`^[a-z][a-z0-9_]{2,39}$`) + +// validateSlug reports why a slug is unusable, or nil when it is fine. +// +// The slug is the one irreversible choice in this module: it fixes +// t.me/addstickers/_by_ forever, because Telegram has no +// rename-short-name method. Rejecting loudly here is much cheaper than a user +// discovering the typo is permanent. +func validateSlug(slug string) error { + if !slugRe.MatchString(slug) { + return refuse(fmt.Sprintf("Pack name must be %d-%d characters: lowercase letters, digits and underscores, starting with a letter.", minSlugLen, maxSlugLen)) + } + if strings.Contains(slug, "__") { + return refuse("Pack name cannot contain two underscores in a row.") + } + if strings.HasSuffix(slug, "_") { + return refuse("Pack name cannot end with an underscore.") + } + return nil +} + +// makeSetName builds the Telegram set name for a new pack. It is used only at +// creation — never to resolve ownership, which compares the *stored* name (see +// ownsSet). +// +// The error reports the remaining budget rather than only refusing, because the +// only fix available to the user is a shorter slug and the limit depends on the +// bot's username length, which they cannot see. +func makeSetName(slug, botUsername string) (string, error) { + if botUsername == "" { + return "", errNoUsername + } + suffix := "_by_" + botUsername + if len(slug)+len(suffix) > maxSetNameLen { + budget := maxSetNameLen - len(suffix) + if budget > maxSlugLen { + budget = maxSlugLen + } + return "", refuse(fmt.Sprintf("Pack name is too long for this bot — use at most %d characters.", budget)) + } + return slug + suffix, nil +} + +// ownsSet reports whether setName is the caller's pack, comparing +// case-insensitively against the *stored* Pack.Name. +// +// It deliberately does not re-derive the name from the live bot username. +// Renaming the bot in BotFather is supported and leaves existing set names +// untouched, so a derived comparison would make every user's own pack refuse as +// "not yours" while /mypack still displayed it. Comparing the stored name also +// sidesteps casing: Telegram returns SetName with whatever casing the set was +// created with. +func ownsSet(pack Pack, setName string) bool { + if pack.Name == "" || setName == "" { + return false + } + return strings.EqualFold(pack.Name, setName) +} + +// usernameResolver caches the bot's username for building new set names. +// +// The bot starts with bot.WithSkipGetMe(), so nothing populates a username +// until this asks. Failures are never cached: a transient GetMe error must not +// disable /newpack for the process's lifetime. +type usernameResolver struct { + mu sync.Mutex + username string +} + +// resolve returns the bot's username, calling GetMe at most once per success. +// It takes the handler's *bot.Bot rather than Deps.Bot, which is documented +// nil-safe and is nil under BuildOptions{}. +func (r *usernameResolver) resolve(ctx context.Context, b *bot.Bot) (string, error) { + r.mu.Lock() + cached := r.username + r.mu.Unlock() + if cached != "" { + return cached, nil + } + + me, err := b.GetMe(ctx) + if err != nil { + return "", err + } + if me == nil || me.Username == "" { + return "", errNoUsername + } + + r.mu.Lock() + r.username = me.Username + r.mu.Unlock() + return me.Username, nil +} diff --git a/internal/modules/sticker/setname_test.go b/internal/modules/sticker/setname_test.go new file mode 100644 index 0000000..c0c1769 --- /dev/null +++ b/internal/modules/sticker/setname_test.go @@ -0,0 +1,97 @@ +package sticker + +import ( + "strings" + "testing" +) + +func TestValidateSlug(t *testing.T) { + cases := []struct { + slug string + ok bool + }{ + {"mypack", true}, + {"my_pack_2", true}, + {"abc", true}, + {strings.Repeat("a", maxSlugLen), true}, + {"ab", false}, // too short + {strings.Repeat("a", maxSlugLen+1), false}, // too long + {"1pack", false}, // leading digit + {"My_Pack", false}, // uppercase + {"my__pack", false}, // consecutive underscores + {"mypack_", false}, // trailing underscore + {"my-pack", false}, // hyphen + {"", false}, + } + for _, tc := range cases { + err := validateSlug(tc.slug) + if tc.ok && err != nil { + t.Errorf("validateSlug(%q) = %v, want nil", tc.slug, err) + } + if !tc.ok && err == nil { + t.Errorf("validateSlug(%q) = nil, want an error", tc.slug) + } + } +} + +func TestMakeSetName(t *testing.T) { + got, err := makeSetName("mypack", "miti99bot") + if err != nil { + t.Fatalf("makeSetName: %v", err) + } + if want := "mypack_by_miti99bot"; got != want { + t.Errorf("makeSetName = %q, want %q", got, want) + } +} + +// The set name has a hard 64-char ceiling and the slug is the only part the +// user controls, so the refusal has to name the budget they actually have. +func TestMakeSetName_TooLongReportsBudget(t *testing.T) { + username := strings.Repeat("b", 30) + slug := strings.Repeat("a", maxSlugLen) + _, err := makeSetName(slug, username) + if err == nil { + t.Fatalf("makeSetName(%d-char slug, %d-char username) succeeded; want a refusal", len(slug), len(username)) + } + // 64 - len("_by_" + username) = 30 + if !strings.Contains(err.Error(), "30") { + t.Errorf("refusal %q does not state the remaining budget", err) + } +} + +func TestOwnsSet(t *testing.T) { + pack := Pack{Slug: "mypack", Name: "mypack_by_bot"} + cases := []struct { + setName string + want bool + }{ + {"mypack_by_bot", true}, + {"MyPack_By_Bot", true}, // Telegram echoes the creation casing + {"otherpack_by_bot", false}, + {"mypack_by_otherbot", false}, + {"", false}, + } + for _, tc := range cases { + if got := ownsSet(pack, tc.setName); got != tc.want { + t.Errorf("ownsSet(%q) = %v, want %v", tc.setName, got, tc.want) + } + } + if ownsSet(Pack{}, "anything") { + t.Error("ownsSet with an empty stored name = true, want false") + } +} + +// Renaming the bot in BotFather leaves existing set names untouched. Ownership +// compares the stored name for exactly this reason: deriving it from the live +// username would make every user's own pack refuse as "not yours". +func TestOwnsSet_SurvivesBotRename(t *testing.T) { + pack := Pack{Slug: "mypack", Name: "mypack_by_oldbot"} + if !ownsSet(pack, "mypack_by_oldbot") { + t.Error("pack stopped resolving after the bot was renamed") + } + // The new username only ever builds *new* names. + fresh, err := makeSetName("newpack", "newbot") + if err != nil || fresh != "newpack_by_newbot" { + t.Errorf("makeSetName after rename = (%q, %v)", fresh, err) + } +} diff --git a/internal/modules/sticker/setpackicon.go b/internal/modules/sticker/setpackicon.go new file mode 100644 index 0000000..b6ad2b0 --- /dev/null +++ b/internal/modules/sticker/setpackicon.go @@ -0,0 +1,56 @@ +package sticker + +import ( + "bytes" + "context" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// handleSetPackIcon sets the pack thumbnail from a sticker already in the pack. +// +// The sticker's own file_id cannot simply be handed to Telegram: a pack +// thumbnail must be exactly 100x100, which a 512px sticker is not. So the image +// is fetched, resized, and uploaded as a new file. +func (s *state) handleSetPackIcon(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + owned, err := s.resolveOwned(ctx, msg, ownerID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_setpackicon_resolve", err) + } + + // Reserve the reply tail before the slow leg. See mediaContext. + mediaCtx, cancelMedia := mediaContext(ctx) + defer cancelMedia() + + raw, err := downloadFile(mediaCtx, b, owned.fileID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_setpackicon_download", err) + } + thumb, err := toThumbnailPNG(raw) + if err != nil { + return replyErr(ctx, b, msg, "sticker_setpackicon_resize", err) + } + + if _, err := b.SetStickerSetThumbnail(ctx, &bot.SetStickerSetThumbnailParams{ + Name: owned.pack.Name, + UserID: ownerID, + Thumbnail: &models.InputFileUpload{Filename: "thumb.png", Data: bytes.NewReader(thumb)}, + Format: stickerFormatStatic, + }); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_setpackicon", err) + } + return reply(ctx, b, msg, "Pack icon updated.") +} diff --git a/internal/modules/sticker/state.go b/internal/modules/sticker/state.go new file mode 100644 index 0000000..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: " ", + Handler: s.handleNewPack, + }, + { + Name: "mypack", + Visibility: modules.VisibilityPublic, + Description: "Show your sticker pack and its link", + Handler: s.handleMyPack, + }, + { + Name: "addsticker", + Visibility: modules.VisibilityPublic, + Description: "Add the replied sticker to your pack", + Parameters: "[emoji...]", + Handler: s.handleAddSticker, + }, + { + Name: "delsticker", + Visibility: modules.VisibilityPublic, + Description: "Remove the replied sticker from your pack", + Handler: s.handleDelSticker, + }, + { + Name: "editsticker", + Visibility: modules.VisibilityPublic, + Description: "Change the emoji of a sticker in your pack", + Parameters: "", + Handler: s.handleEditSticker, + }, + { + Name: "ordersticker", + Visibility: modules.VisibilityPublic, + Description: "Move a sticker in your pack to a position", + Parameters: "", + Handler: s.handleOrderSticker, + }, + { + Name: "setpackicon", + Visibility: modules.VisibilityPublic, + Description: "Set your pack's icon from a sticker in it", + Handler: s.handleSetPackIcon, + }, + { + Name: "renamepack", + Visibility: modules.VisibilityPublic, + Description: "Change your pack's title (the link cannot change)", + Parameters: "", + Handler: s.handleRenamePack, + }, + { + Name: "delpack", + Visibility: modules.VisibilityPublic, + Description: "Delete your pack after confirmation", + Handler: s.handleDelPack, + }, + }, + Callbacks: []modules.Callback{{ + Prefix: callbackPrefix, + Visibility: modules.VisibilityPublic, + Handler: s.handleDelPackCallback, + }}, + } +} diff --git a/internal/modules/sticker/sticker_handlers.go b/internal/modules/sticker/sticker_handlers.go new file mode 100644 index 0000000..4dab023 --- /dev/null +++ b/internal/modules/sticker/sticker_handlers.go @@ -0,0 +1,224 @@ +package sticker + +import ( + "context" + "fmt" + "strconv" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" +) + +// maxStickersPerPack is Telegram's documented ceiling for a regular set. It is +// not enforced locally — the server is the authority and a local copy would go +// stale — but it is quoted back to the user when the server refuses. +const maxStickersPerPack = 120 + +// handleAddSticker adds the replied sticker (or, from Phase 5, photo) to the +// caller's pack. +func (s *state) handleAddSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + // Every argument is an emoji: with no pack token to disambiguate, a stray + // word fails here rather than being silently read as something else. + emoji, err := parseEmoji(commandArgs(msg)) + if err != nil { + return replyErr(ctx, b, msg, "sticker_addsticker_emoji", err) + } + + pack, found, err := getPack(ctx, s.store, ownerID) + if err != nil { + log.Error("sticker_addsticker_load", "err", err) + return reply(ctx, b, msg, genericFailure) + } + // Unlike resolveOwned's deliberately uniform refusal, this one is specific: + // it answers only "do *you* have a pack", about the caller's own state, and + // so discloses nothing about anyone else. + if !found { + return reply(ctx, b, msg, noPackYet) + } + if pack.Pending { + return reply(ctx, b, msg, noPackYet+pendingMarker) + } + + source, err := s.resolveSource(ctx, b, ownerID, msg) + if err != nil { + return replyErr(ctx, b, msg, "sticker_addsticker_source", err) + } + + // Precedence: explicit args, then the replied sticker's own emoji, then the + // default. Telegram requires at least one. + if len(emoji) == 0 { + emoji = source.emoji + } + if len(emoji) == 0 { + emoji = []string{defaultEmoji} + } + + defer s.lockUser(ownerID)() + + _, err = b.AddStickerToSet(ctx, &bot.AddStickerToSetParams{ + UserID: ownerID, // always the caller: a non-owner never reaches this call + Name: pack.Name, + Sticker: models.InputSticker{ + Sticker: source.fileID, + Format: stickerFormatStatic, + EmojiList: emoji, + }, + }) + if err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_addsticker", err) + } + + updated, err := s.adjustCount(ctx, ownerID, +1) + if err != nil { + // The sticker is already in the set; only our count is stale. + log.Error("sticker_addsticker_commit", "err", err) + updated = pack + updated.Count++ + } + return reply(ctx, b, msg, fmt.Sprintf("Added to %s (%d stickers).\n%s", + updated.Title, updated.Count, shareLink(updated.Name))) +} + +// handleDelSticker removes the replied sticker from the caller's pack. +// +// It deliberately does *not* probe afterwards to see whether the set survived. +// Whether removing the last sticker also destroys the set is undocumented, and +// an earlier design that probed would have deleted the pack record whenever the +// probe merely failed — so a 429, a DNS blip, or a SIGTERM during a routine +// delete would erase the only record of a live pack. Deleting the record needs +// a positive signal, and the next command's STICKERSET_INVALID is one. +func (s *state) handleDelSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + owned, err := s.resolveOwned(ctx, msg, ownerID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_delsticker_resolve", err) + } + + // Same read-modify-write on Count as /addsticker, so the same lock. + defer s.lockUser(ownerID)() + + if _, err := b.DeleteStickerFromSet(ctx, &bot.DeleteStickerFromSetParams{Sticker: owned.fileID}); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_delsticker", err) + } + + pack, err := s.adjustCount(ctx, ownerID, -1) + if err != nil { + // The sticker is already gone from the set; only our count is stale. + log.Error("sticker_delsticker_commit", "err", err) + pack = owned.pack + if pack.Count > 0 { + pack.Count-- + } + } + + if pack.Count == 0 { + // Telegram may have removed the now-empty set. /mypack makes no API + // calls so it cannot notice, and /newpack stays blocked while a record + // exists — so name the command that clears it. + return reply(ctx, b, msg, "Removed. Your pack is now empty, and Telegram may have deleted it. "+ + "If /addsticker says the pack is gone, use /delpack to clear it and /newpack to start again.") + } + return reply(ctx, b, msg, fmt.Sprintf("Removed. %s now has %d sticker(s).", pack.Title, pack.Count)) +} + +// handleEditSticker replaces the emoji of a sticker in the caller's pack. +func (s *state) handleEditSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + emoji, err := parseEmoji(commandArgs(msg)) + if err != nil { + return replyErr(ctx, b, msg, "sticker_editsticker_emoji", err) + } + // An empty emoji_list is invalid, so this cannot fall back to a default the + // way /addsticker does: the user has to say what they want. + if len(emoji) == 0 { + return reply(ctx, b, msg, "Usage: /editsticker \nReply to a sticker in your pack with at least one emoji.") + } + + owned, err := s.resolveOwned(ctx, msg, ownerID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_editsticker_resolve", err) + } + + if _, err := b.SetStickerEmojiList(ctx, &bot.SetStickerEmojiListParams{ + Sticker: owned.fileID, + EmojiList: emoji, + }); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_editsticker", err) + } + return reply(ctx, b, msg, "Emoji updated.") +} + +// handleOrderSticker moves a sticker to a new position in the caller's pack. +func (s *state) handleOrderSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { + ctx, cancel := handlerContext(ctx) + defer cancel() + + msg := update.Message + ownerID, err := senderID(msg) + if err != nil { + return reply(ctx, b, msg, senderRefusal) + } + + args := commandArgs(msg) + if len(args) != 1 { + return reply(ctx, b, msg, "Usage: /ordersticker \nReply to a sticker in your pack. Positions start at 0.") + } + pos, err := strconv.Atoi(args[0]) + if err != nil || pos < 0 { + // Only the lower bound is checked locally. The upper bound is the set's + // current size, which Telegram knows and a local copy would not. + return reply(ctx, b, msg, "Position must be a whole number, 0 or greater.") + } + + owned, err := s.resolveOwned(ctx, msg, ownerID) + if err != nil { + return replyErr(ctx, b, msg, "sticker_ordersticker_resolve", err) + } + + if _, err := b.SetStickerPositionInSet(ctx, &bot.SetStickerPositionInSetParams{ + Sticker: owned.fileID, + Position: pos, + }); err != nil { + if isStickerSetMissing(err) { + s.dropPackRecord(ctx, ownerID) + } + return replyAPIError(ctx, b, msg, "sticker_ordersticker", err) + } + return reply(ctx, b, msg, fmt.Sprintf("Moved to position %d.", pos)) +}