diff --git a/.env.example b/.env.example index de5c28b..ab603a4 100644 --- a/.env.example +++ b/.env.example @@ -14,14 +14,20 @@ MONGO_DATABASE=miti99bot # ============================ Operational ========================= # 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 +# module deliberately. +MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd # 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). ADMIN_IDS= +# Sticker set /addsticker writes to. Every user contributes to this one pack. +# MUST end in _by_: Telegram requires that suffix on sets a +# bot creates and refuses to edit sets it did not create, so the suffix is what +# proves the pack is manageable. Created automatically, owned by OWNER_ID, on +# the first /addsticker if it does not exist yet. Unset = the default below. +STICKER_PACK_NAME=miti99_by_miti99bot + # SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM. # Do NOT set it here. Coolify provides it at runtime. Keep "Include Source # Commit in Build" disabled so Docker layer cache survives across commits. diff --git a/Dockerfile b/Dockerfile index 0f7dc63..29d9534 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,9 +19,21 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o /out/server \ ./cmd/server -FROM gcr.io/distroless/static:nonroot +# Alpine rather than distroless/static, for one reason: /addsticker transcodes +# video and GIF sources to WEBM/VP9, which needs ffmpeg. Telegram accepts no +# other codec for a video sticker, Go's standard library has no VP9 encoder +# (golang.org/x/image/vp8 decodes only), and the binary is built CGO_ENABLED=0 +# so a cgo encoder would not link either. ffmpeg is therefore a hard runtime +# dependency, and distroless/static has no way to carry it. +# +# ffmpeg comes from Alpine's own package rather than a third-party static-ffmpeg +# image, keeping the supply chain to bases already trusted by this build. +FROM alpine:3.22 # No fonts are installed here: the monkeyd module's PDF renderer falls back to a # font compiled into the binary when the host has none. +RUN apk add --no-cache ffmpeg ca-certificates \ + && addgroup -g 65532 -S nonroot \ + && adduser -u 65532 -S -G nonroot nonroot COPY --from=builder /out/server /server USER nonroot:nonroot EXPOSE 8080 diff --git a/README.md b/README.md index d83eee1..7e5d594 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Atlas via long polling and an in-process cron scheduler. | Module | What it does | |---|---| -| `util` | `/help`, `/info`, `/stickerid` | +| `util` | `/help`, `/info`, `/stickerid`, `/addsticker` (adds a replied sticker, image, video or GIF to one shared pack — see [docs/sticker-packs.md](docs/sticker-packs.md)) | | `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/xlt1`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | | `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only | | `wordle` | Daily Wordle game | @@ -18,7 +18,6 @@ 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 a8d20f1..77d2753 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -72,15 +72,7 @@ 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": "", diff --git a/cmd/server/main.go b/cmd/server/main.go index fe3b13a..7acf76e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,7 +26,6 @@ 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" @@ -92,7 +91,6 @@ func factories() map[string]modules.Factory { "gold": gold.New, stock.CollectionName: stock.New, "stats": stats.New, - "sticker": sticker.New, } } diff --git a/docs/sticker-packs.md b/docs/sticker-packs.md index a982ab3..bf96f2e 100644 --- a/docs/sticker-packs.md +++ b/docs/sticker-packs.md @@ -1,158 +1,178 @@ -# Sticker packs +# Sticker pack -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 +`/addsticker` appends a sticker to **one shared pack** that every user of the +bot contributes to. It lives in the `util` module — there is no `sticker` +module any more, and no per-user packs. | 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 | +| `/addsticker` | `[emoji...]` | sticker, photo, or image document | Adds it to the shared pack and replies with the link | -Only `/newpack` names a pack. Every other command resolves your single pack from -storage, or from the replied sticker's set. +Single-shot: one message, optionally replying to a sticker or image. No +conversation state. -## The pack name is permanent +## Configuration -`/newpack mypack My Pack` creates `t.me/addstickers/mypack_by_`. +| Env | Default | Meaning | +|---|---|---| +| `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to | +| `OWNER_ID` | — | Must be the account that **owns** that set | -**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. +`OWNER_ID` is reused rather than given a sticker-specific twin because +`addStickerToSet` takes the **set owner's** user ID, not the caller's, and the +default pack belongs to the bot owner. Point `OWNER_ID` at the owning account if +the configured pack belongs to someone else. -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. +The caller's identity is used nowhere. That is what makes the command stateless: +no records, no keys, no per-user locks, and no ownership checks. It is also why +`/addsticker` needs no storage and fits in `util`. -Pack-name rules: 3–40 characters, lowercase letters, digits and underscores, -starting with a letter, no two underscores in a row, no trailing underscore. +`STICKER_PACK_NAME` **must end in `_by_`** — Telegram +requires that suffix on every set a bot creates, and refuses to let a bot edit +any set it did not create. A name without the suffix therefore cannot be this +bot's, which makes it a configuration fault the command can prove offline, +before any download or API call: -## Images +> The shared pack is not one this bot can manage. Ask the bot owner to check its +> configuration. -Static stickers only. Animated, video, mask, and custom-emoji stickers are -rejected. +## Creating the pack -- 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. +**The pack creates itself on first use.** If the set does not exist, the first +`/addsticker` creates it — owned by `OWNER_ID`, titled with the slug half of the +name (`miti99_by_miti99bot` → *miti99*), and seeded with the sticker that +triggered it, because Telegram cannot create an empty set. The reply says +*"Created the shared pack with this sticker."* -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. +Rename the title afterwards through `@Stickers` if the derived one is not wanted. +The name itself is permanent — Telegram has no method to rename a set's short +name. -## Who can use it +The order is add-first, create-on-missing, not probe-then-add: probing with +`getStickerSet` would cost an extra call on every invocation forever, and return +the set's entire sticker list each time, to save one call on the single +invocation that creates the pack. -Public — every user manages their own pack. +**A name already taken by a set this bot cannot write to is an error, not a +takeover.** If the add reports the set missing *and* the create reports the name +occupied, something stands there that this bot cannot manage — a set created by +another bot, or by this bot for a different owner: -**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. +> A sticker set with that name already exists and this bot cannot manage it. Ask +> the bot owner to check it. -## Deliberate omissions +**The owner cannot be verified up front.** `getStickerSet` returns a set's name, +title, type, stickers and thumbnail — and no owner or creator ID. So the suffix +proves *which bot* created a set, but nothing proves *which user* owns it until +Telegram refuses the write. That refusal is what the message above reports. -- **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. +## No moderation, by design + +Anyone who can reach the bot can add to the pack. There is no approval step, no +per-contributor limit, and no way to remove a sticker through the bot — +`/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`, `/renamepack` +and `/delpack` were removed along with the per-user model. + +Cleanup is done by the pack's owner through Telegram's own `@Stickers` bot, +which can edit any set the owner owns. Two consequences worth accepting on +purpose before enabling this: + +- The pack carries the owner's name and sits in the sticker tray of everyone who + installed it, but its contents are decided by whoever runs the command. +- The 120-sticker ceiling is shared. One user can fill it. + +## What it accepts + +| Replied message | Result | +|---|---| +| **Sticker** — static, animated (.TGS), or video (.WEBM) | Copied by `file_id`, no conversion | +| **Photo** or image **document** (`image/png`, `image/jpeg`, `image/webp`) | Converted to a 512px PNG and uploaded | +| **Video, GIF, animation, video note** | Transcoded to a WEBM/VP9 video sticker | +| **Mask or custom-emoji sticker** | Refused — the pack is a `regular` set | + +**All three sticker formats go in the same pack.** Since Bot API 7.2 the format +is a property of each sticker, not of the set: `createNewStickerSet` lost its +`sticker_format` parameter and `StickerSet` lost `is_animated`/`is_video`. So a +pack seeded with a static sticker takes video stickers later with no migration. + +**A sticker is copied, never converted.** It already lives in a set Telegram +accepted, so it satisfies every dimension, duration and size rule for its +format. That is why animated and video *stickers* work here while an ordinary +video or GIF *file* does not. Copying a sticker out of someone else's pack is +normal Telegram behaviour and does not touch that pack. + +A replied photo or image document is downloaded, resized so its long edge is +exactly 512px with the aspect ratio preserved, uploaded as a PNG, and then +added — attributed to the pack owner, matching the set it is about to join. +Sources above 2 MB, or with either side above 4096px, are rejected. + +## Video and GIF + +A replied video, GIF, animation or video note is downloaded and transcoded with +**ffmpeg** to what Telegram requires of a video sticker: WEBM/VP9, long edge +exactly 512px, at most 3 seconds, at most 30 FPS, at most 256 KB, and **no +audio stream**. Documents count too — `image/gif`, `video/mp4`, `video/webm`, +`video/quicktime`, `video/x-matroska`. + +Every one of those rules is applied by the filter chain and encoder flags rather +than checked afterwards, so there is no case where a source slips through +half-converted: + +- The long edge is scaled to exactly 512 **in either direction**. A 100×50 GIF + becomes 512×256 — deliberately not `force_original_aspect_ratio=decrease`, + which leaves a small source undersized and so fails the "one side must be + exactly 512" rule. +- `-t 3` cuts the length; `fps=30` caps the rate. +- `-an -sn -dn` drops audio, subtitle and data streams. Telegram refuses a video + sticker carrying audio, so this is not merely tidiness. +- `-pix_fmt yuva420p` keeps GIF transparency; VP9 carries an alpha plane. + +Size is the one rule that cannot be known before encoding, so it retries down a +CRF ladder (32 → 42 → 52) until the output fits. Ordinary footage lands around +30–70 KB at the first rung, well inside the limit. If even the last rung is too +big, the smallest attempt is sent and Telegram is left to be the authority. + +`image/gif` is transcoded rather than reduced to its first frame — a GIF is sent +to be animated. (Note that a GIF forwarded through Telegram usually arrives as +an `Animation` in mp4 form, not as `image/gif` at all.) + +**ffmpeg is a hard runtime dependency.** Telegram accepts no other codec for a +video sticker, Go's standard library has no VP9 encoder +(`golang.org/x/image/vp8` decodes only), and the binary is built +`CGO_ENABLED=0` so a cgo encoder would not link. The runtime image is therefore +`alpine` with `apk add ffmpeg` rather than `distroless/static`, which cannot +carry a second binary — the one reason that base was given up, and it takes the +image from roughly 20 MB to 213 MB. + +**A transcode holds the whole bot.** Handlers run inline on a single worker, so +an encode is time no other user is served. A 1280×720 source encodes in about +0.4 s with these flags, and one encode is capped at 20 s so a pathological input +fails rather than hangs. The moving path also gets a longer handler deadline +(45 s, against 10 s for stills) — chosen from the replied message before any API +call, so a still never pays for the video budget. Source downloads are capped at +10 MB for video against 2 MB for images, since the ceiling is on the input and +the 256 KB limit applies to the output. + +Telegram allows 120 stickers per pack and 1–20 emoji per sticker. Emoji come +from the command's arguments, else the replied sticker's own emoji, else `⭐`. +There is no documented file-size limit for static stickers; the code applies its +own client-side ceiling and never presents it as a Telegram rule. ## 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. +**The reply carries no sticker count.** Nothing is stored, and reading the count +back would mean a `getStickerSet` call that returns the entire set on every add. -**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. +**Download errors are never echoed.** A Telegram file URL embeds the bot token, +and every transport failure from the HTTP client formats that URL into its error +text. The download path replaces all of them with one opaque error and logs only +a coarse type label, so no failure mode can print the token. -`/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. +**Telegram's refusals are translated, not forwarded.** `STICKERS_TOO_MUCH`, +`STICKERSET_INVALID` and the emoji errors get sentences a user can act on; +anything unrecognised becomes a generic line plus an ERROR log. -A name is claimed *before* the bot calls Telegram, not after the pack exists. -That ordering is what keeps two users from racing for the same name: the first -claimant wins it and everyone else is refused before any set is created. - -The claim is given up again whenever the bot has positive evidence that no pack -stands behind it — Telegram refusing the creation outright, `/delpack`, or a -later command finding the set already gone. A `/newpack` that never got as far -as claiming, or that is refused before Telegram is contacted, leaves nothing -behind. - -The claim is deliberately **not** treated as proof of ownership over a set that -already exists. See "The bot never takes over an existing pack" below. - -Telegram may keep a deleted short name reserved on its own side, so a freed name -is not guaranteed to be usable again by anyone, including its previous owner. - -**Ownership refusals are identical by design.** "You don't have a pack" and -"that sticker isn't from your pack" produce the exact same reply. Distinct -wording would let anyone probe which sets exist under this bot. - -**The bot never takes over an existing pack.** If a set already exists under the -name you ask for, `/newpack` refuses — always, for everyone, whatever the bot's -records say about it. - -Earlier versions adopted such a set when local records suggested it came from -your own interrupted attempt. That was wrong in a way no amount of checking -fixes: every fact the bot could use to prove "this set is yours" lives in the -same storage a restart erases, while the packs at Telegram survive. Once the -proof is gone, a genuine interrupted attempt and a stranger naming your pack's -public link present the bot with identical evidence. The feature was the hole, -so the feature is gone. - -The cost is real and worth stating plainly: if the bot crashes between creating -your set at Telegram and recording it, the set is stranded. It exists, it is -linkable, and no command in this bot can manage or delete it. `/mypack` marks -the unfinished attempt, and `/delpack` clears the leftover record so you can -create a pack under a different name — but it will not delete anything at -Telegram, because an unfinished record is not evidence that the bot made that -set for you. Anyone can produce such a record for any name. - -Run this module against a real database. On the in-memory backend every restart -strands every pack. - -**Deleting the last sticker may delete the pack.** Telegram's behaviour here is -undocumented, so the bot does not guess: it will not remove your pack record on -anything less than a positive "this set no longer exists" from Telegram. If a -command reports the pack is gone, `/delpack` clears the stale record and -`/newpack` works again. - -## Operations - -The module is enabled by listing `sticker` in `MODULES` (an empty `MODULES` -loads every module). It stores one record per user, keyed by Telegram user ID, -plus at most one pending `/delpack` confirmation per user — running `/delpack` -again supersedes the previous prompt, and a confirmation stops working after 10 -minutes. - -Every handler runs under a 10-second deadline. The bot processes updates one at -a time, so this bound is what keeps an image conversion from stalling other -users. +The handler runs under a 10-second deadline, with the download-and-upload leg +bounded inside it so the reply always has budget left. The bot processes updates +one at a time, so that bound is what keeps an image conversion from stalling +other users. diff --git a/internal/modules/sticker/delpack_callback.go b/internal/modules/sticker/delpack_callback.go deleted file mode 100644 index 10756de..0000000 --- a/internal/modules/sticker/delpack_callback.go +++ /dev/null @@ -1,270 +0,0 @@ -package sticker - -import ( - "context" - "errors" - "fmt" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/miti99bot/internal/log" - "github.com/tiennm99/miti99bot/internal/storage" -) - -// handleDelPack asks for confirmation before destroying the caller's pack. -// -// /delpack is also the *only* way to change a pack's URL, since Telegram has no -// rename-short-name method. That makes this prompt the last point at which a -// user who came here wanting a new link learns that the stickers do not survive -// the change — so it states the title, the count being lost, the exact link -// being surrendered, and that both are permanent. -func (s *state) handleDelPack(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - msg := update.Message - ownerID, err := senderID(msg) - if err != nil { - return reply(ctx, b, msg, senderRefusal) - } - - pack, found, err := getPack(ctx, s.store, ownerID) - if err != nil { - log.Error("sticker_delpack_load", "err", err) - return reply(ctx, b, msg, genericFailure) - } - if !found { - return reply(ctx, b, msg, noPackYet) - } - - // A pending record is bookkeeping, not proof that this bot created a set - // under that name on this user's behalf — /newpack writes it before - // Telegram is called, and anyone can make one naming any set. Deleting by - // set name is authorised by Telegram for every set this bot created, so - // confirming a delete from a pending record would let one user destroy - // another's pack. Clear the local record only, and touch nothing upstream. - if pack.Pending { - defer s.lockUser(ownerID)() - s.dropPackRecord(ctx, ownerID) - return reply(ctx, b, msg, fmt.Sprintf( - "Cleared an unfinished attempt at %s and freed the name. Nothing was deleted at Telegram; if that attempt did create a pack, it is no longer reachable through this bot.", - pack.Slug)) - } - - id, err := newActionID() - if err != nil { - log.Error("sticker_delpack_id", "err", err) - return reply(ctx, b, msg, genericFailure) - } - - now := s.now() - action := PendingDelete{ - ID: id, - OwnerID: ownerID, - Slug: pack.Slug, - SetName: pack.Name, - ChatID: msg.Chat.ID, - CreatedAt: now.UnixMilli(), - ExpiresAt: now.Add(pendingDeleteTTL).UnixMilli(), - } - - sent, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: msg.Chat.ID, - ReplyParameters: &models.ReplyParameters{MessageID: msg.ID}, - Text: fmt.Sprintf( - "Delete %s (%s)?\n\nThis destroys %d sticker(s) and gives up %s permanently. Neither can be recovered, and the link may not be reusable.", - pack.Title, pack.Slug, pack.Count, shareLink(pack.Name)), - ReplyMarkup: &models.InlineKeyboardMarkup{ - InlineKeyboard: [][]models.InlineKeyboardButton{{ - {Text: "Delete permanently", CallbackData: deleteCallbackData(id)}, - }}, - }, - }) - if err != nil { - log.Error("sticker_delpack_prompt", "err", err) - return err - } - - // Bind the action to the message carrying the button, so a press from a - // forwarded or replayed copy resolves to nothing. - action.MessageID = sent.ID - commitCtx, cancelCommit := commitContext(ctx) - defer cancelCommit() - if err := s.pending.Put(commitCtx, pendingDeleteKey(ownerID), action); err != nil { - log.Error("sticker_delpack_store", "err", err) - return reply(ctx, b, msg, genericFailure) - } - return nil -} - -// handleDelPackCallback consumes a confirm press. -func (s *state) handleDelPackCallback(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - if update == nil || update.CallbackQuery == nil { - return nil - } - query := update.CallbackQuery - - id, ok := parseDeleteCallback(query.Data) - if !ok { - return answerCallback(ctx, b, query.ID, "This confirmation is invalid.") - } - - // The lookup is keyed by the presser, never by the payload: the payload is - // client-controlled, so using it to choose *whose* action to load would let - // anyone address someone else's confirmation. - if query.From.ID == 0 { - return answerCallback(ctx, b, query.ID, "This confirmation is invalid.") - } - key := pendingDeleteKey(query.From.ID) - action, _, err := s.pending.Get(ctx, key) - if errors.Is(err, storage.ErrNotFound) { - return answerCallback(ctx, b, query.ID, "This confirmation expired or was already used.") - } - if err != nil { - log.Error("sticker_delpack_action_load", "err", err) - return answerCallback(ctx, b, query.ID, "Could not load this confirmation. Try /delpack again.") - } - - // Unreachable by construction — the action was loaded under this presser's - // own key, so a foreign press already returned above. Kept as defence in - // depth against a future change to how actions are addressed. - if query.From.ID != action.OwnerID { - return answerCallback(ctx, b, query.ID, "Only the user who ran /delpack can confirm it.") - } - - // CallbackQuery.Message is a MaybeInaccessibleMessage: nil for messages - // Telegram considers inaccessible. The panic barrier is the backstop, not a - // reason to skip the guard — and this must come before any use of msg. - msg := query.Message.Message - if msg == nil { - return answerCallback(ctx, b, query.ID, "This confirmation is no longer valid here.") - } - - // Binding first, and with no side effect. This press may be on somebody - // else's prompt: anyone in a group can tap anyone's button, so touching the - // message before proving it is the one this action was written for let a - // bystander strip the button off a live confirmation they had no part in. - // - // It also subsumes the stale-prompt case — an older prompt is a different - // message id, so it fails here. - if msg.Chat.ID != action.ChatID || msg.ID != action.MessageID || action.MessageID == 0 { - return answerCallback(ctx, b, query.ID, "This confirmation is no longer valid here.") - } - - // Defence in depth: the binding above already implies this, since a newer - // /delpack writes a new message id. Clearing is safe here only because the - // binding proved this is the caller's own bound message. - if action.ID != id { - clearButton(ctx, b, msg.Chat.ID, msg.ID) - return answerCallback(ctx, b, query.ID, "This confirmation was replaced by a newer /delpack.") - } - - if action.ExpiresAt <= s.now().UnixMilli() { - s.dropPendingDelete(ctx, key) - clearButton(ctx, b, action.ChatID, action.MessageID) - return answerCallback(ctx, b, query.ID, "This confirmation expired. Run /delpack again.") - } - - defer s.lockUser(action.OwnerID)() - - // Re-establish, under the lock, that the caller still holds this exact set. - // - // The authority to delete comes from the record, not from the prompt, and a - // prompt outlives the record: /delpack can sit unpressed for ten minutes - // while the pack disappears from Telegram's side, a self-heal frees the - // name, and somebody else claims it. DeleteStickerSet is keyed by set name - // and Telegram authorises it for every set this bot created, so a press - // then lands on whoever holds the name at that moment. - // - // Stated as an allowlist deliberately. The first version of this guard - // listed the states it would refuse — a pending record still naming this - // set — and fell through on the two that mattered: no record at all, and a - // record that had moved on to a different pack. Proving authority is the - // only formulation that fails closed against a state nobody thought of. - current, found, err := getPack(ctx, s.store, action.OwnerID) - if err != nil { - log.Error("sticker_delpack_recheck", "err", err) - return answerCallback(ctx, b, query.ID, "Could not confirm right now. Try /delpack again.") - } - // !found is stated explicitly even though ownsSet already returns false for - // a zero-value record's empty Name — mutation testing shows it is currently - // redundant. It stays because that redundancy is an accident of ownsSet's - // empty-string guard, not something this check should depend on. - if !found || current.Pending || !ownsSet(current, action.SetName) { - s.dropPendingDelete(ctx, key) - clearButton(ctx, b, action.ChatID, action.MessageID) - return answerCallback(ctx, b, query.ID, - "This confirmation is out of date — that pack is no longer yours to delete. Run /delpack again if you still want to.") - } - - // Consume the action *before* the destructive call, so a double press - // cannot delete twice or race a second confirmation. - if err := s.pending.Delete(ctx, key); err != nil { - if errors.Is(err, storage.ErrNotFound) { - return answerCallback(ctx, b, query.ID, "This confirmation was already used.") - } - log.Error("sticker_delpack_consume", "err", err) - return answerCallback(ctx, b, query.ID, "Could not confirm right now. Try /delpack again.") - } - - _, err = b.DeleteStickerSet(ctx, &bot.DeleteStickerSetParams{Name: action.SetName}) - switch { - case err == nil, isStickerSetMissing(err): - // Missing counts as success: the set is gone either way, and clearing - // the record is what unblocks /newpack. - // - // But clear it only if it still names *this* set. dropPackRecord deletes - // by owner, and the user's record may have moved on to a different pack - // since this confirmation was written — that is exactly what the - // documented /delpack-then-/newpack URL-change route does. Deleting - // blindly by owner would then erase a live pack's record. - s.dropPackRecordIfSet(ctx, action.OwnerID, action.SetName) - clearButton(ctx, b, action.ChatID, action.MessageID) - // Reply to the prompt rather than sending bare to the chat: in a forum - // supergroup a bare ChatID send lands in General instead of the topic - // the button lives in, leaking the pack name across topics and losing - // the confirmation. chathelper.Reply carries MessageThreadID. - _ = reply(ctx, b, msg, fmt.Sprintf("Deleted %s. You can create a new pack with /newpack.", action.Slug)) - return answerCallback(ctx, b, query.ID, "Pack deleted.") - - default: - // The record stays: we have no positive signal that the set is gone. - log.Error("sticker_delpack_delete", "err", err) - clearButton(ctx, b, action.ChatID, action.MessageID) - return answerCallback(ctx, b, query.ID, "Telegram refused the delete. Your pack is unchanged.") - } -} - -func (s *state) dropPendingDelete(ctx context.Context, key string) { - commitCtx, cancel := commitContext(ctx) - defer cancel() - if err := s.pending.Delete(commitCtx, key); err != nil && !errors.Is(err, storage.ErrNotFound) { - log.Error("sticker_drop_pending_delete", "err", err) - } -} - -func answerCallback(ctx context.Context, b *bot.Bot, queryID, text string) error { - _, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ - CallbackQueryID: queryID, - Text: text, - ShowAlert: true, - }) - return err -} - -// clearButton removes the inline keyboard so a spent prompt cannot be pressed -// again. Best effort: the action is already consumed either way. -func clearButton(ctx context.Context, b *bot.Bot, chatID int64, messageID int) { - _, err := b.EditMessageReplyMarkup(ctx, &bot.EditMessageReplyMarkupParams{ - ChatID: chatID, - MessageID: messageID, - ReplyMarkup: &models.InlineKeyboardMarkup{}, - }) - if err != nil { - log.Error("sticker_clear_button", "err", err) - } -} diff --git a/internal/modules/sticker/delpack_callback_test.go b/internal/modules/sticker/delpack_callback_test.go deleted file mode 100644 index 6397941..0000000 --- a/internal/modules/sticker/delpack_callback_test.go +++ /dev/null @@ -1,489 +0,0 @@ -package sticker - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/miti99bot/internal/testutil" -) - -const promptMessageID = 555 - -// seedPendingDelete stores a confirm action as /delpack would have. -func seedPendingDelete(t *testing.T, s *state, mutate func(*PendingDelete)) PendingDelete { - t.Helper() - action := PendingDelete{ - ID: "abc123", - OwnerID: testUser, - Slug: "mypack", - SetName: testSet, - ChatID: testChat, - MessageID: promptMessageID, - CreatedAt: fixedNow.UnixMilli(), - ExpiresAt: fixedNow.Add(pendingDeleteTTL).UnixMilli(), - } - if mutate != nil { - mutate(&action) - } - if err := s.pending.Put(context.Background(), pendingDeleteKey(action.OwnerID), action); err != nil { - t.Fatalf("seed pending delete: %v", err) - } - return action -} - -// confirmPress builds the callback update for pressing the confirm button. -func confirmPress(action PendingDelete, presser int64) *models.Update { - return &models.Update{CallbackQuery: &models.CallbackQuery{ - ID: "cbq-1", - From: models.User{ID: presser}, - Data: deleteCallbackData(action.ID), - Message: models.MaybeInaccessibleMessage{ - Message: &models.Message{ - ID: action.MessageID, - Chat: models.Chat{ID: action.ChatID}, - }, - }, - }} -} - -// The prompt is the last point at which a user changing their pack's URL learns -// the stickers do not survive it, so it must state all four consequences. -func TestDelPack_PromptStatesConsequences(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 47) - - if err := s.handleDelPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil { - t.Fatalf("handleDelPack: %v", err) - } - - text := rb.LastSent().Text() - for _, want := range []string{"My Pack", "47", shareLink(testSet), "permanent"} { - if !strings.Contains(text, want) { - t.Errorf("confirm prompt %q missing %q", text, want) - } - } - if countMethod(rb, "deleteStickerSet") != 0 { - t.Error("/delpack deleted without confirmation") - } -} - -func TestDelPack_CallbackDataFitsTelegramLimit(t *testing.T) { - id, err := newActionID() - if err != nil { - t.Fatalf("newActionID: %v", err) - } - data := deleteCallbackData(id) - if len(data) > maxCallbackBytes { - t.Errorf("callback data is %d bytes, over the %d-byte limit", len(data), maxCallbackBytes) - } - got, ok := parseDeleteCallback(data) - if !ok || got != id { - t.Errorf("parseDeleteCallback(%q) = (%q, %v), want (%q, true)", data, got, ok, id) - } -} - -func TestDelPackCallback_HappyPath(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 3) - action := seedPendingDelete(t, s, nil) - - if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - if countMethod(rb, "deleteStickerSet") != 1 { - t.Fatalf("methods = %v, want one deleteStickerSet", methodsSent(rb)) - } - if _, found := loadPack(t, s); found { - t.Error("pack record survived a confirmed delete") - } -} - -// Identity comes from From.ID, never from the payload — the payload is -// client-controlled. -// A foreign presser gets nothing. The mechanism is the key, not the owner -// comparison: the action is loaded by the presser's own id, so someone else's -// press finds no action at all and returns before the owner check is reached. -// -// Named for that, because the previous name claimed to exercise the owner -// comparison at delpack_callback.go and did not — that branch is unreachable -// by construction, and is kept only as defence in depth. -func TestDelPackCallback_ForeignPresserResolvesToNothing(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 3) - action := seedPendingDelete(t, s, nil) - - if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser+1)); err != nil { - t.Fatalf("callback: %v", err) - } - if countMethod(rb, "deleteStickerSet") != 0 { - t.Errorf("methods = %v, want no delete", methodsSent(rb)) - } - if _, found := loadPack(t, s); !found { - t.Error("another user's press deleted the pack record") - } -} - -func TestDelPackCallback_RejectsExpired(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 3) - action := seedPendingDelete(t, s, func(a *PendingDelete) { - a.ExpiresAt = fixedNow.Add(-time.Second).UnixMilli() - }) - - if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - if countMethod(rb, "deleteStickerSet") != 0 { - t.Errorf("methods = %v, want no delete after expiry", methodsSent(rb)) - } - if _, found := loadPack(t, s); !found { - t.Error("an expired press deleted the pack record") - } -} - -// The action is bound to the message carrying the button, so a press arriving -// from anywhere else resolves to nothing. -func TestDelPackCallback_RejectsWrongBinding(t *testing.T) { - cases := map[string]func(*models.Update){ - "different chat": func(u *models.Update) { u.CallbackQuery.Message.Message.Chat.ID = testChat + 1 }, - "different message": func(u *models.Update) { u.CallbackQuery.Message.Message.ID = promptMessageID + 1 }, - // MaybeInaccessibleMessage is nil for messages Telegram marks - // inaccessible; the panic barrier is a backstop, not a substitute. - "inaccessible message": func(u *models.Update) { u.CallbackQuery.Message.Message = nil }, - } - for name, mutate := range cases { - t.Run(name, func(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 3) - action := seedPendingDelete(t, s, nil) - - upd := confirmPress(action, testUser) - mutate(upd) - - if err := s.handleDelPackCallback(context.Background(), rb.Bot, upd); err != nil { - t.Fatalf("callback: %v", err) - } - if countMethod(rb, "deleteStickerSet") != 0 { - t.Errorf("methods = %v, want no delete", methodsSent(rb)) - } - if _, found := loadPack(t, s); !found { - t.Error("pack record deleted despite a broken binding") - } - }) - } -} - -// Single use: the action is consumed before the destructive call, so a second -// press finds nothing. -func TestDelPackCallback_SecondPressIsInert(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 3) - action := seedPendingDelete(t, s, nil) - - press := func() { - if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - } - press() - press() - - if got := countMethod(rb, "deleteStickerSet"); got != 1 { - t.Errorf("deleteStickerSet calls = %d, want exactly 1", got) - } -} - -// A set Telegram already lost still clears the record — that is what unblocks -// /newpack after the phantom-record failure mode. -func TestDelPackCallback_MissingSetStillClearsRecord(t *testing.T) { - rb := testutil.NewRecordingBot(t) - rb.FailMethodCode("deleteStickerSet", 400, "Bad Request: STICKERSET_INVALID") - s := newTestState() - seedPack(t, s, 3) - action := seedPendingDelete(t, s, nil) - - if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - if _, found := loadPack(t, s); found { - t.Error("record survived; /newpack stays blocked") - } -} - -// The mirror image: an unclassifiable failure leaves the pack alone. -func TestDelPackCallback_TransientErrorKeepsRecord(t *testing.T) { - rb := testutil.NewRecordingBot(t) - rb.FailMethod("deleteStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) - s := newTestState() - seedPack(t, s, 3) - action := seedPendingDelete(t, s, nil) - - if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - if _, found := loadPack(t, s); !found { - t.Error("a transient delete failure destroyed the pack record") - } -} - -func TestDelPackCallback_IgnoresNonCallbackUpdate(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - if err := s.handleDelPackCallback(context.Background(), rb.Bot, &models.Update{}); err != nil { - t.Fatalf("callback with no query: %v", err) - } - if len(rb.Sent()) != 0 { - t.Errorf("methods = %v, want none", methodsSent(rb)) - } -} - -// The stale-confirmation regression. /delpack is the documented route to a new -// pack URL (delete, then /newpack under a new name), so a user genuinely can -// have an old prompt in scrollback while holding a *different*, live pack. -// -// Pressing the stale button deleted the old set, got STICKERSET_INVALID, and -// then cleared the record by owner id — erasing the record of the new, live -// pack and orphaning it permanently. -func TestDelPackCallback_StalePressLeavesTheCurrentPackAlone(t *testing.T) { - rb := testutil.NewRecordingBot(t) - // Registered so that if the guard ever lets the call through, it is the - // assertion below that reports it rather than a confusing downstream error. - rb.FailMethodCode("deleteStickerSet", 400, "Bad Request: STICKERSET_INVALID") - s := newTestState() - ctx := context.Background() - - // A confirmation written for the *old* pack. - stale := seedPendingDelete(t, s, func(a *PendingDelete) { - a.Slug = "oldslug" - a.SetName = "oldslug_by_testbot" - }) - - // The user has since created a new pack. - current := Pack{Slug: "newslug", Name: "newslug_by_testbot", Title: "New", OwnerID: testUser, Count: 7} - if err := s.store.Put(ctx, packKey(testUser), current); err != nil { - t.Fatalf("seed current pack: %v", err) - } - - if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(stale, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - - // Nothing may reach Telegram. The old name may since have been claimed by - // another user, and DeleteStickerSet is keyed by name alone — asserting only - // that this user's own record survived misses the cross-user damage - // entirely, which is how this went unnoticed. - if n := countMethod(rb, "deleteStickerSet"); n != 0 { - t.Errorf("deleteStickerSet calls = %d, want 0 — a stale confirmation reached Telegram", n) - } - - pack, found := loadPack(t, s) - if !found { - t.Fatal("the live pack's record was deleted by a stale confirmation") - } - if pack.Name != current.Name || pack.Count != 7 { - t.Errorf("pack = %+v, want the live newslug record untouched", pack) - } -} - -// Two /delpack runs must not leave two live capabilities. The second prompt -// supersedes the first, and pressing the first afterwards does nothing. -func TestDelPack_SecondPromptSupersedesTheFirst(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 3) - ctx := context.Background() - - run := func() PendingDelete { - if err := s.handleDelPack(ctx, rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil { - t.Fatalf("handleDelPack: %v", err) - } - action, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)) - if err != nil { - t.Fatalf("load action: %v", err) - } - return action - } - first := run() - second := run() - - if first.ID == second.ID { - t.Fatal("both prompts share an id; the test cannot distinguish them") - } - // Exactly one action is stored, not two. - keys, err := s.pending.List(ctx, pendingDeletePrefix) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(keys) != 1 { - t.Errorf("stored pending actions = %d, want 1 — a public command must not accumulate documents", len(keys)) - } - - // The superseded button is inert. - if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(first, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - if countMethod(rb, "deleteStickerSet") != 0 { - t.Errorf("methods = %v, want the stale button to delete nothing", methodsSent(rb)) - } - if _, found := loadPack(t, s); !found { - t.Error("the superseded button deleted the pack") - } -} - -// Anyone in a group can tap anyone's inline button. Checking supersession -// before the chat/message binding meant a bystander's press stripped the button -// off a live confirmation they had no part in — no data leak, but the victim's -// prompt was destroyed and they had to start over. -func TestDelPackCallback_BystanderCannotTouchAnotherUsersPrompt(t *testing.T) { - const bystander = int64(2) - - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 3) - ctx := context.Background() - - victimAction := seedPendingDelete(t, s, nil) - - // The bystander has their own pending confirmation, bound to their own - // message — this is what made action.ID differ and triggered the clear. - bystanderAction := PendingDelete{ - ID: "bbbb2222", OwnerID: bystander, Slug: "theirs", SetName: "theirs_by_testbot", - ChatID: testChat, MessageID: 777, - CreatedAt: fixedNow.UnixMilli(), ExpiresAt: fixedNow.Add(pendingDeleteTTL).UnixMilli(), - } - if err := s.pending.Put(ctx, pendingDeleteKey(bystander), bystanderAction); err != nil { - t.Fatalf("seed bystander action: %v", err) - } - - // The bystander presses the victim's button. - if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(victimAction, bystander)); err != nil { - t.Fatalf("callback: %v", err) - } - - if countMethod(rb, "editMessageReplyMarkup") != 0 { - t.Errorf("methods = %v; a bystander cleared the button on someone else's prompt", methodsSent(rb)) - } - if countMethod(rb, "deleteStickerSet") != 0 { - t.Errorf("methods = %v, want no delete", methodsSent(rb)) - } - // The victim's confirmation is untouched and still usable. - if _, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)); err != nil { - t.Errorf("victim's pending action was consumed by a bystander's press: %v", err) - } -} - -// A deleted pack must give its name back. Holding it forever would shrink the -// global namespace permanently and let a /newpack + /delpack loop burn one name -// per cycle. -func TestDelPackCallback_ReleasesTheName(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 3) - ctx := context.Background() - action := seedPendingDelete(t, s, nil) - - if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); !held { - t.Fatal("fixture is wrong: the pack should start with its name reserved") - } - - if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(action, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { - t.Error("the name is still reserved after the pack was deleted") - } -} - -// A confirmation must never outlive the authority it was issued under. -// -// Each case leaves the PendingDelete intact and breaks the pack record a -// different way, so the press actually reaches the under-lock allowlist. An -// earlier version of this test called dropPackRecord, which now clears the -// confirmation too — so the callback returned at the pending.Get miss ~50 lines -// before the guard, and the test passed with the whole guard reverted. Every -// disjunct is exercised here on purpose. -// -// The damage is cross-user: DeleteStickerSet is keyed by set name, which -// Telegram authorises for every set this bot created, so a press with stale -// authority destroys whoever holds that name at press time. -func TestDelPackCallback_StaleAuthorityNeverReachesTelegram(t *testing.T) { - const victim = int64(99) - - cases := []struct { - name string - break_ func(t *testing.T, s *state, ctx context.Context) - }{ - { - // !found — a self-heal removed the record but the prompt survived. - name: "record gone", - break_: func(t *testing.T, s *state, ctx context.Context) { - if err := s.store.Delete(ctx, packKey(testUser)); err != nil { - t.Fatalf("delete record: %v", err) - } - }, - }, - { - // current.Pending — the record is an unconfirmed attempt, which is - // no evidence this bot made that set for this user. - name: "record is unconfirmed", - break_: func(t *testing.T, s *state, ctx context.Context) { - pack, _ := loadPack(t, s) - pack.Pending = true - if err := s.store.Put(ctx, packKey(testUser), pack); err != nil { - t.Fatalf("mark pending: %v", err) - } - }, - }, - { - // !ownsSet — the record has moved on to a different pack. - name: "record moved on", - break_: func(t *testing.T, s *state, ctx context.Context) { - if err := s.store.Put(ctx, packKey(testUser), Pack{ - Slug: "newslug", Name: "newslug_by_testbot", Title: "New", OwnerID: testUser, Count: 7, - }); err != nil { - t.Fatalf("move record: %v", err) - } - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - ctx := context.Background() - - seedPack(t, s, 3) - action := seedPendingDelete(t, s, nil) - tc.break_(t, s, ctx) - - // Someone else now holds that name, with a live set behind it. - if err := s.store.Put(ctx, packKey(victim), Pack{ - Slug: "mypack", Name: testSet, Title: "V's pack", OwnerID: victim, Count: 5, - }); err != nil { - t.Fatalf("seed victim: %v", err) - } - - if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(action, testUser)); err != nil { - t.Fatalf("callback: %v", err) - } - - if n := countMethod(rb, "deleteStickerSet"); n != 0 { - t.Errorf("deleteStickerSet calls = %d, want 0 — a stale confirmation destroyed the current holder's pack", n) - } - if _, _, err := s.store.Get(ctx, packKey(victim)); err != nil { - t.Errorf("victim's pack record damaged: %v", err) - } - }) - } -} diff --git a/internal/modules/sticker/errors.go b/internal/modules/sticker/errors.go deleted file mode 100644 index ba3e510..0000000 --- a/internal/modules/sticker/errors.go +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index 12ad720..0000000 --- a/internal/modules/sticker/handlers_test.go +++ /dev/null @@ -1,440 +0,0 @@ -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/pack.go b/internal/modules/sticker/pack.go deleted file mode 100644 index edd56f2..0000000 --- a/internal/modules/sticker/pack.go +++ /dev/null @@ -1,104 +0,0 @@ -// Package sticker lets any user create and manage one personal Telegram -// sticker pack through the bot. The pack is created on behalf of the calling -// user, named "_by_", and stays bot-manageable because the -// bot created it. -// -// One pack per user is the central simplification: no command except /newpack -// takes a pack argument, because there is only ever one pack to act on. -package sticker - -import ( - "context" - "errors" - "strconv" - - "github.com/tiennm99/miti99bot/internal/storage" -) - -// Pack is the single bot-created sticker set owned by a Telegram user. -// -// The record is keyed by owner ID alone, which makes the lookup itself the -// ownership check: there is no way to read a pack without naming its owner. -type Pack struct { - Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL - Name string `bson:"name"` // Telegram set name, "_by_" - Title string `bson:"title"` // display title, mutable via /renamepack - OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to - Count int `bson:"count"` // stickers in the set; keeps /mypack API-free - Pending bool `bson:"pending"` // write-ahead intent; see newpack's state machine - CreatedAt int64 `bson:"createdAt"` // unix millis -} - -// PackStore is the module's view over its collection. -type PackStore = storage.DocStore[Pack] - -// SlugReservation records which user claimed a pack name, globally. -// -// Pack records are keyed by owner, so they can only answer "does *this* user -// have a pack" — they cannot answer "who holds this name". Without that second -// question the module cannot tell its own interrupted attempt from a set -// belonging to someone else, because both look identical from the caller's -// side: a pending record naming a set that exists. -// -// The reservation answers the question the pack record cannot: who is entitled -// to create under a given name. It is claimed with a create-only write before -// Telegram is touched, so the first claimant wins the name and everyone else is -// refused before any set exists. -// -// It is NOT ownership of whatever set may already sit under that name. Nothing -// in this module grants that, because nothing stored here survives the wipe -// that would make the grant necessary. -type SlugReservation struct { - Slug string `bson:"slug"` - OwnerID int64 `bson:"ownerId"` - CreatedAt int64 `bson:"createdAt"` -} - -// SlugStore is the third typed view over the module's collection. -type SlugStore = storage.DocStore[SlugReservation] - -// slugKey namespaces reservations away from the owner-keyed Pack records. -// Pack keys are decimal owner IDs, so the prefix cannot collide. -func slugKey(slug string) string { return slugPrefix + slug } - -const slugPrefix = "slug:" - -// getSlugReservation reads a name's reservation. Missing is not an error — it -// is the normal state for an unclaimed name. -func getSlugReservation(ctx context.Context, store SlugStore, slug string) (SlugReservation, bool, error) { - r, _, err := store.Get(ctx, slugKey(slug)) - if errors.Is(err, storage.ErrNotFound) { - return SlugReservation{}, false, nil - } - if err != nil { - return SlugReservation{}, false, err - } - return r, true, nil -} - -// packKey is the storage key for a user's pack: the owner ID and nothing else. -// -// One pack per user makes the slug unnecessary as a key component, which is -// what removes the prefix scan the multi-pack design needed. The module calls -// List nowhere. -func packKey(ownerID int64) string { return strconv.FormatInt(ownerID, 10) } - -// getPack reads the caller's pack. A missing record is not an error — it is the -// normal state for a user who has never run /newpack — so it reports found -// rather than returning storage.ErrNotFound for every caller to translate. -func getPack(ctx context.Context, store PackStore, ownerID int64) (Pack, bool, error) { - p, _, err := store.Get(ctx, packKey(ownerID)) - if errors.Is(err, storage.ErrNotFound) { - return Pack{}, false, nil - } - if err != nil { - return Pack{}, false, err - } - return p, true, nil -} - -// shareLink is the public URL of a pack. It is fixed at creation and cannot be -// changed: Telegram exposes no method to rename a set's short name. -func shareLink(setName string) string { - return "https://t.me/addstickers/" + setName -} diff --git a/internal/modules/sticker/pack_handlers.go b/internal/modules/sticker/pack_handlers.go deleted file mode 100644 index 6cfaa53..0000000 --- a/internal/modules/sticker/pack_handlers.go +++ /dev/null @@ -1,614 +0,0 @@ -package sticker - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/miti99bot/internal/log" - "github.com/tiennm99/miti99bot/internal/storage" -) - -const ( - newpackUsage = "Reply to a sticker with: /newpack \nEg: /newpack mypack My Pack" - // slugTaken answers a name held by anyone other than the caller. It says - // only "taken", never who holds it — the plan accepts that pack names are - // enumerable (share links are public), but there is no reason to confirm - // which are backed by real packs any more precisely than Telegram already - // does. - slugTaken = "That pack name is taken. Pick a different one." - noPackYet = "You don't have a pack yet. Reply to a sticker with /newpack to create one." - pendingMarker = "\n\n⚠️ This pack is incomplete — re-run the same /newpack command to finish it." -) - -// handleNewPack creates the caller's pack. -// -// The hard part is not the API call, it is surviving an interruption. The slug -// fixes a permanent public URL, so an attempt that dies between "Telegram -// created the set" and "we wrote it down" would strand that slug forever: the -// set exists, nobody's record points at it, and the user cannot recreate it. -// -// The fix is a write-ahead record, claimed before Telegram is called, in two -// parts that answer two different questions: -// -// - a global reservation on the name — "who claimed this name?" (reserveSlug) -// - an owner-keyed pending Pack — "does this user have a pack?" (claimSlug) -// -// Both are needed. The pending record alone proves only that this caller *asked -// for* the name, which a user naming someone else's pack also does. The -// reservation settles who gets to create under a name; neither of them, nor the -// two together, is ever treated as permission to take over a set that already -// exists — see createPack. -func (s *state) handleNewPack(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - msg := update.Message - ownerID, err := senderID(msg) - if err != nil { - return reply(ctx, b, msg, senderRefusal) - } - - args := commandArgs(msg) - if len(args) < 2 { - return reply(ctx, b, msg, newpackUsage) - } - slug := strings.ToLower(args[0]) - if err := validateSlug(slug); err != nil { - return replyErr(ctx, b, msg, "sticker_newpack_slug", err) - } - title := strings.TrimSpace(strings.Join(args[1:], " ")) - if title == "" || len([]rune(title)) > maxTitleLen { - return reply(ctx, b, msg, fmt.Sprintf("Give a title of 1-%d characters.", maxTitleLen)) - } - - // The lock covers everything below, including the media leg. It is per-user, - // so a slow download delays only the user who sent it. - defer s.lockUser(ownerID)() - - // Refuse a caller who already has a finished pack BEFORE reserving anything, - // and before doing any expensive work. - // - // Reservations are permanent and global, so writing one and *then* - // discovering the caller is not entitled to a pack handed every user an - // unlimited name-burning primitive: each refused /newpack claimed a name for - // everyone else, at the cost of one message and zero API calls. The order of - // this check and reserveSlug is the whole defence. - // - // It also runs before resolveSource, which downloads, resamples and - // re-uploads an image. Answering "you already have a pack" is a single store - // read; making a user who cannot create a pack pay for the full media - // pipeline first was free work for anyone who wanted to spend the bot's CPU. - if existing, found, err := getPack(ctx, s.store, ownerID); err != nil { - log.Error("sticker_newpack_precheck", "err", err) - return reply(ctx, b, msg, genericFailure) - } else if found && !existing.Pending { - return reply(ctx, b, msg, fmt.Sprintf( - "You already have a pack (%s). Use /delpack first if you want a different one.\n%s", - existing.Slug, shareLink(existing.Name))) - } - - source, err := s.resolveSource(ctx, b, ownerID, msg) - if err != nil { - return replyErr(ctx, b, msg, "sticker_newpack_source", err) - } - - username, err := s.resolver.resolve(ctx, b) - if err != nil { - log.Error("sticker_newpack_username", "err", err) - return reply(ctx, b, msg, genericFailure) - } - setName, err := makeSetName(slug, username) - if err != nil { - return replyErr(ctx, b, msg, "sticker_newpack_setname", err) - } - - created, done, err := s.reserveSlug(ctx, b, msg, ownerID, slug) - if err != nil || done { - return err - } - - claimed, done, err := s.claimSlug(ctx, b, msg, ownerID, slug, setName, title) - if err != nil || done { - // Nothing downstream is holding this name: release it rather than - // leaving a permanent claim with no pack behind it. Only what this - // invocation created — never a reservation we merely resumed. - if created { - s.releaseSlug(ctx, ownerID, slug) - } - return err - } - - return s.createPack(ctx, b, msg, claimed, source) -} - -// reserveSlug claims a pack name for ownerID, globally and permanently. -// -// Pack records are keyed by owner, so they answer "does this user have a pack" -// and nothing else — a user naming someone else's slug produces exactly the -// same record as one resuming their own interrupted attempt. The reservation -// adds the missing fact about the *name*: who is entitled to create under it. -// -// A create-only write on the name itself is what keeps two users from racing -// for the same name: the first claimant wins it, and everyone else is refused -// before any set is created. It is deliberately NOT treated as proof of -// ownership over a set that already exists — see createPack for why no local -// fact can carry that weight. -// -// created reports whether this call wrote the reservation, so a caller that -// bails can release exactly what it made and never a reservation it merely -// resumed. done == true means the caller was already answered and the handler -// must stop. -func (s *state) reserveSlug(ctx context.Context, b *bot.Bot, msg *models.Message, ownerID int64, slug string) (created, done bool, err error) { - err = s.slugs.PutVersioned(ctx, slugKey(slug), 0, SlugReservation{ - Slug: slug, - OwnerID: ownerID, - CreatedAt: s.now().UnixMilli(), - }) - if err == nil { - return true, false, nil - } - if !errors.Is(err, storage.ErrConflict) { - log.Error("sticker_newpack_reserve", "err", err) - return false, true, reply(ctx, b, msg, genericFailure) - } - - held, found, getErr := getSlugReservation(ctx, s.slugs, slug) - if getErr != nil || !found { - // Conflict but unreadable: treat the name as unavailable rather than - // guessing. Guessing the other way is the takeover. - log.Error("sticker_newpack_reserve_read", "err", getErr) - return false, true, reply(ctx, b, msg, slugTaken) - } - if held.OwnerID != ownerID { - return false, true, reply(ctx, b, msg, slugTaken) - } - // Our own reservation from an earlier attempt: carry on and resume it, but - // do not claim we created it — releasing it on a later bail would discard a - // claim that predates this command. - return false, false, nil -} - -// releaseSlug frees a reservation whose pack was never created, or was deleted. -// -// Only ever called with a positive signal that the name is not in use — never on -// an unknown error, which would hand the name to whoever asks next while the set -// may still exist. -// -// It verifies ownership itself rather than trusting call sites. A bare -// delete-by-name is a cross-user primitive, and this module has already been -// bitten once by an ownership check that lived in the caller instead of the -// operation. -func (s *state) releaseSlug(ctx context.Context, ownerID int64, slug string) { - // The ownership read runs on the detached context too, not just the delete. - // Splitting them meant a cancelled request (SIGTERM, or the handler - // deadline) failed the read and returned before the delete — leaving a - // reservation with no pack and no set behind it, which no code path can - // ever reach again. A cleanup that only half-survives shutdown is worse - // than one that does not run at all. - commitCtx, cancel := commitContext(ctx) - defer cancel() - - held, found, err := getSlugReservation(commitCtx, s.slugs, slug) - if err != nil { - log.Error("sticker_release_slug_read", "slug", slug, "err", err) - return - } - if !found { - return - } - if held.OwnerID != ownerID { - log.Error("sticker_release_slug_refused", "slug", slug, "holder", held.OwnerID, "caller", ownerID) - return - } - if err := s.slugs.Delete(commitCtx, slugKey(slug)); err != nil && !errors.Is(err, storage.ErrNotFound) { - log.Error("sticker_release_slug", "slug", slug, "err", err) - } -} - -// claimSlug writes the write-ahead intent record, or interprets the conflict -// when the caller already has one. -// -// done == true means the caller was already answered and the handler must stop. -func (s *state) claimSlug(ctx context.Context, b *bot.Bot, msg *models.Message, ownerID int64, slug, setName, title string) (Pack, bool, error) { - intent := Pack{ - Slug: slug, - Name: setName, - Title: title, - OwnerID: ownerID, - Pending: true, - CreatedAt: s.now().UnixMilli(), - } - - // PutVersioned with expectedVersion 0 is create-only, and Mongo resolves it - // with a duplicate-key error, so exactly one writer wins. This record *is* - // the one-pack-per-user quota — there is no separate counter to keep in - // sync. Put would silently overwrite and must not be used here. - err := s.store.PutVersioned(ctx, packKey(ownerID), 0, intent) - if err == nil { - return intent, false, nil - } - if !errors.Is(err, storage.ErrConflict) { - log.Error("sticker_newpack_claim", "err", err) - return Pack{}, true, reply(ctx, b, msg, genericFailure) - } - - existing, found, getErr := getPack(ctx, s.store, ownerID) - if getErr != nil || !found { - log.Error("sticker_newpack_reread", "err", getErr) - return Pack{}, true, reply(ctx, b, msg, genericFailure) - } - - switch { - case !existing.Pending: - return Pack{}, true, reply(ctx, b, msg, fmt.Sprintf( - "You already have a pack (%s). Use /delpack first if you want a different one.\n%s", - existing.Slug, shareLink(existing.Name))) - - case existing.Slug == slug: - // Our own interrupted attempt for this exact name: resume it, but with - // the title from *this* command. Returning the stored record verbatim - // silently discarded a retyped title and then reported success using - // the old one — "/newpack mypack New Title" answering "Created Old." - resumed := existing - resumed.Title = intent.Title - // Name is deliberately NOT refreshed. It is derived from the bot's - // username, which can change at BotFather; the stored one names the set - // the interrupted attempt may already have created, and repointing it - // would orphan that set and aim later commands at a different name. - return resumed, false, nil - - default: - // An earlier attempt was interrupted under a *different* name. Probe - // before overwriting, so a set that attempt did create is logged as - // stranded rather than silently forgotten. - return s.resolveStaleIntent(ctx, b, msg, existing, intent) - } -} - -// resolveStaleIntent decides what to do with a pending record for a slug the -// caller is no longer asking for. -func (s *state) resolveStaleIntent(ctx context.Context, b *bot.Bot, msg *models.Message, existing, intent Pack) (Pack, bool, error) { - // Establish whether this caller still holds the old name before touching - // anything under it. They normally do — the pending record came from their - // own earlier run through reserveSlug — but it is re-read rather than - // inferred from the pending record. - held, found, err := getSlugReservation(ctx, s.slugs, existing.Slug) - if err != nil { - log.Error("sticker_newpack_stale_reservation", "err", err) - return Pack{}, true, reply(ctx, b, msg, genericFailure) - } - if !found || held.OwnerID != intent.OwnerID { - // Someone else holds the old name, or it was released. Either way the - // old intent is dead; replace it and let the caller proceed with the - // name they actually asked for. - if putErr := s.store.Put(ctx, packKey(intent.OwnerID), intent); putErr != nil { - log.Error("sticker_newpack_replace_intent", "err", putErr) - return Pack{}, true, reply(ctx, b, msg, genericFailure) - } - return intent, false, nil - } - - _, err = b.GetStickerSet(ctx, &bot.GetStickerSetParams{Name: existing.Name}) - switch { - case err == nil: - // The old name is occupied. This branch used to adopt, on the strength - // of a reservation it re-read here — and it never consulted the newer - // per-invocation guard at all, so it stayed a takeover route after that - // guard was added. No adoption happens anywhere in this module now. - // - // The old reservation stays: a set genuinely exists under that name, so - // the name is not free, and releasing it would only send the next - // caller down this same refusal. The dead intent is replaced so the - // caller can get on with the name they actually asked for. - if putErr := s.store.Put(ctx, packKey(intent.OwnerID), intent); putErr != nil { - log.Error("sticker_newpack_replace_intent", "err", putErr) - return Pack{}, true, reply(ctx, b, msg, genericFailure) - } - log.Error("sticker_newpack_stranded_set", "slug", existing.Slug, "owner", existing.OwnerID) - return intent, false, nil - - case isStickerSetMissing(err): - // Nothing was created under the old name; take over the record. A - // positive "no such set" is what makes releasing the old reservation - // safe — otherwise an abandoned name would be held against every other - // user forever, with no set behind it. - if putErr := s.store.Put(ctx, packKey(intent.OwnerID), intent); putErr != nil { - log.Error("sticker_newpack_replace_intent", "err", putErr) - return Pack{}, true, reply(ctx, b, msg, genericFailure) - } - s.releaseSlug(ctx, intent.OwnerID, existing.Slug) - return intent, false, nil - - default: - // Unknown failure: change nothing (plan rule 4). - log.Error("sticker_newpack_probe", "err", err) - return Pack{}, true, reply(ctx, b, msg, genericFailure) - } -} - -// createPack performs the Telegram-side creation for a claimed intent. -// -// It never takes over a set that already exists, for anybody, under any local -// evidence. Adoption was the source of four consecutive takeover holes, and the -// reason is structural rather than a bug that can be patched: every fact this -// module could use to prove "that set is mine to finish" lives in the same -// store that a restart on the in-memory backend erases, while the packs at -// Telegram survive. Once the proof is gone, a real interrupted attempt and an -// attacker naming a victim's public slug present identical evidence. -// -// Removing the branch removes the class. Nothing local can be forged into -// rights over an existing set, because no local fact grants them. -func (s *state) createPack(ctx context.Context, b *bot.Bot, msg *models.Message, pack Pack, source stickerSource) error { - _, err := b.GetStickerSet(ctx, &bot.GetStickerSetParams{Name: pack.Name}) - switch { - case err == nil: - // Occupied. Leave nothing behind: a pending record naming a set this - // caller does not own is itself a weapon, because /delpack deletes by - // set name and Telegram authorises that on any set this bot created — - // including the real owner's. - s.dropIntent(ctx, pack.OwnerID) - s.releaseSlug(ctx, pack.OwnerID, pack.Slug) - return reply(ctx, b, msg, slugTaken) - - case isStickerSetMissing(err): - // Free: create it. - - default: - // Unknown. Keep both the intent and the reservation: the set may exist, - // and re-running is how the user recovers. Destroying either here is - // what strands a slug (plan rule 4). - log.Error("sticker_newpack_lookup", "err", err) - return reply(ctx, b, msg, genericFailure) - } - - emoji := source.emoji - if len(emoji) == 0 { - emoji = []string{defaultEmoji} - } - _, err = b.CreateNewStickerSet(ctx, &bot.CreateNewStickerSetParams{ - UserID: pack.OwnerID, - Name: pack.Name, - Title: pack.Title, - Stickers: []models.InputSticker{{ - Sticker: source.fileID, - Format: stickerFormatStatic, - EmojiList: emoji, - }}, - }) - if err != nil { - // Only a refusal that proves nothing was created lets us undo the - // claim. On anything else the create may have succeeded server-side, so - // both the intent and the reservation stay — a re-run then reports the - // name as taken rather than guessing, which is the safe direction. - if createRefused(err) { - s.dropIntent(ctx, pack.OwnerID) - s.releaseSlug(ctx, pack.OwnerID, pack.Slug) - } - return replyAPIError(ctx, b, msg, "sticker_newpack_create", err) - } - return s.finishNewPack(ctx, b, msg, pack) -} - -// finishNewPack commits the confirmed record and replies with the share link. -func (s *state) finishNewPack(ctx context.Context, b *bot.Bot, msg *models.Message, pack Pack) error { - pack.Pending = false - if pack.Count == 0 { - pack.Count = 1 - } - if err := s.commitPack(ctx, pack); err != nil { - log.Error("sticker_newpack_commit", "err", err) - return reply(ctx, b, msg, genericFailure) - } - return reply(ctx, b, msg, fmt.Sprintf("Created %s.\n%s\n\nAdd more with /addsticker while replying to a sticker.", - pack.Title, shareLink(pack.Name))) -} - -// dropIntent removes a write-ahead record whose creation never happened, so the -// user is not left holding a slug for a set that does not exist. -func (s *state) dropIntent(ctx context.Context, ownerID int64) { - commitCtx, cancel := commitContext(ctx) - defer cancel() - if err := s.store.Delete(commitCtx, packKey(ownerID)); err != nil && !errors.Is(err, storage.ErrNotFound) { - log.Error("sticker_drop_intent", "user", ownerID, "err", err) - } -} - -// adjustCount applies a delta to the caller's sticker count and commits. -// -// It re-reads the record rather than trusting the copy the handler resolved -// earlier: that read happened *before* the per-user lock was taken, so writing -// a count derived from it would clobber any change made in between. Reading and -// writing both inside the lock is what makes the lock mean anything. -// -// Returns the committed record so the reply can quote the new count. -func (s *state) adjustCount(ctx context.Context, ownerID, delta int64) (Pack, error) { - // Detached: the sticker has already been added or removed at Telegram by - // the time this runs, so the read-modify-write that records it must not be - // abandoned because the request context expired mid-flight. - commitCtx, cancel := commitContext(ctx) - defer cancel() - - pack, found, err := getPack(commitCtx, s.store, ownerID) - if err != nil { - return Pack{}, err - } - if !found { - // The record vanished under us — nothing to update, and nothing that - // justifies recreating it. - return Pack{}, storage.ErrNotFound - } - pack.Count += int(delta) - if pack.Count < 0 { - // Count is advisory and drifts when a pack is edited through @Stickers. - // It must never go negative. - pack.Count = 0 - } - return pack, s.commitPack(commitCtx, pack) -} - -// commitPack writes a record that reflects a completed Telegram-side action, on -// a context detached from the request. See commitContext. -func (s *state) commitPack(ctx context.Context, pack Pack) error { - commitCtx, cancel := commitContext(ctx) - defer cancel() - return s.store.Put(commitCtx, packKey(pack.OwnerID), pack) -} - -// dropPackRecordIfSet deletes the caller's pack record only when it still names -// setName. -// -// dropPackRecord addresses a record by owner, which is right when the caller is -// acting on the pack they just resolved. It is wrong for a deferred action like -// a /delpack confirmation, which can be pressed after the record has moved on to -// a different pack — deleting by owner then destroys the record of a set that is -// very much alive. -func (s *state) dropPackRecordIfSet(ctx context.Context, ownerID int64, setName string) { - // Detached like the drop it guards: a cancelled read here would skip a - // cleanup that the set's confirmed deletion has already made mandatory. - commitCtx, cancel := commitContext(ctx) - defer cancel() - - pack, found, err := getPack(commitCtx, s.store, ownerID) - if err != nil { - log.Error("sticker_drop_record_check", "user", ownerID, "err", err) - return - } - if !found { - return - } - if !ownsSet(pack, setName) { - // The record already points at a different pack; leave it alone. - return - } - s.dropPackRecord(commitCtx, ownerID) -} - -// dropPackRecord deletes a pack record and frees the name it held. -// -// Every call site reaches here on a positive "this set is gone" signal — either -// isStickerSetMissing, or a confirmed DeleteStickerSet — so the name genuinely -// has no pack behind it any more and must return to the pool. Keeping it would -// shrink the global namespace permanently and let a /newpack + /delpack loop -// burn one name per cycle. -// -// If Telegram reserves deleted short names on its side (plan R11, unverified), -// releasing here is simply a no-op in practice: the next claimant reserves the -// name locally, then CreateNewStickerSet refuses with PACK_SHORT_NAME_OCCUPIED, -// which createRefused releases again. Either way the user gets a correct answer. -// -// Callers must never reach this on a transient failure. -func (s *state) dropPackRecord(ctx context.Context, ownerID int64) { - // Read before deleting: the record is the only thing that knows which name - // this owner held. The read shares the delete's detached context — on the - // request context it would fail during shutdown while the delete below - // still succeeded, stranding the name permanently. - commitCtx, cancel := commitContext(ctx) - defer cancel() - - pack, found, err := getPack(commitCtx, s.store, ownerID) - if err != nil { - log.Error("sticker_drop_record_read", "user", ownerID, "err", err) - // Still drop the record — leaving it would block /newpack — but the - // name cannot be freed without knowing it. - } - - if delErr := s.store.Delete(commitCtx, packKey(ownerID)); delErr != nil && !errors.Is(delErr, storage.ErrNotFound) { - log.Error("sticker_drop_record", "user", ownerID, "err", delErr) - return - } - - // Any outstanding /delpack confirmation was issued against the record just - // removed, so it no longer authorises anything. The callback re-checks too; - // this keeps a dead prompt from surviving to be pressed at all. - s.dropPendingDelete(commitCtx, pendingDeleteKey(ownerID)) - - if err == nil && found && pack.Slug != "" { - s.releaseSlug(commitCtx, ownerID, pack.Slug) - } -} - -// handleMyPack shows the caller's pack. Makes zero API calls: the count lives -// on the record, which is the whole reason it is stored. -func (s *state) handleMyPack(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - msg := update.Message - ownerID, err := senderID(msg) - if err != nil { - return reply(ctx, b, msg, senderRefusal) - } - - pack, found, err := getPack(ctx, s.store, ownerID) - if err != nil { - log.Error("sticker_mypack", "err", err) - return reply(ctx, b, msg, genericFailure) - } - if !found { - return reply(ctx, b, msg, noPackYet) - } - - text := fmt.Sprintf("%s (%s)\n%d sticker(s)\n%s", pack.Title, pack.Slug, pack.Count, shareLink(pack.Name)) - if pack.Pending { - // Showing an unfinished attempt beats hiding it: the user is blocked - // from /newpack until it resolves, and re-running the same command is - // what resolves it. - text += pendingMarker - } - return reply(ctx, b, msg, text) -} - -// handleRenamePack changes the pack's display title. The share link cannot -// follow — Telegram has no rename-short-name method. -func (s *state) handleRenamePack(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - msg := update.Message - ownerID, err := senderID(msg) - if err != nil { - return reply(ctx, b, msg, senderRefusal) - } - - title := strings.TrimSpace(commandArgText(msg)) - if title == "" || len([]rune(title)) > maxTitleLen { - return reply(ctx, b, msg, fmt.Sprintf("Usage: /renamepack \nGive a title of 1-%d characters.", maxTitleLen)) - } - - pack, found, err := getPack(ctx, s.store, ownerID) - if err != nil { - log.Error("sticker_renamepack_load", "err", err) - return reply(ctx, b, msg, genericFailure) - } - if !found || pack.Pending { - return reply(ctx, b, msg, noPackYet) - } - - defer s.lockUser(ownerID)() - - if _, err := b.SetStickerSetTitle(ctx, &bot.SetStickerSetTitleParams{Name: pack.Name, Title: title}); err != nil { - if isStickerSetMissing(err) { - s.dropPackRecord(ctx, ownerID) - } - return replyAPIError(ctx, b, msg, "sticker_renamepack", err) - } - - pack.Title = title - if err := s.commitPack(ctx, pack); err != nil { - // The rename already happened on Telegram's side; only our copy of the - // title is stale, and the next successful rename fixes it. - log.Error("sticker_renamepack_commit", "err", err) - } - - // Naming the delete-and-recreate route turns a dead end into an answer. - // A user who typed "rename" with only a title is likely expecting the URL - // to follow, and it never can. - return reply(ctx, b, msg, fmt.Sprintf( - "Renamed to %s.\nThe link is unchanged: %s\n\nTo get a different link you have to /delpack and then /newpack under a new name — the stickers do not come along.", - title, shareLink(pack.Name))) -} diff --git a/internal/modules/sticker/pack_handlers_test.go b/internal/modules/sticker/pack_handlers_test.go deleted file mode 100644 index eaa63bc..0000000 --- a/internal/modules/sticker/pack_handlers_test.go +++ /dev/null @@ -1,943 +0,0 @@ -package sticker - -import ( - "context" - "strings" - "testing" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/miti99bot/internal/testutil" -) - -const getMeResult = `{"id":7,"is_bot":true,"first_name":"Test","username":"testbot"}` - -// stubBotIdentity makes the username resolver work. The bot starts with -// WithSkipGetMe, so nothing populates a username until the module asks. -func stubBotIdentity(rb *testutil.RecordingBot) { rb.StubMethod("getMe", getMeResult) } - -// setMissing makes getStickerSet report the set does not exist — the only -// classification that lets the module treat a slug as free. -func setMissing(rb *testutil.RecordingBot) { - rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID") -} - -// setExists makes getStickerSet return a real set, which needs a struct result -// the bare harness cannot produce. -func setExists(rb *testutil.RecordingBot) { - rb.StubMethod("getStickerSet", `{"name":"`+testSet+`","title":"My Pack","sticker_type":"regular","stickers":[]}`) -} - -func TestNewPack_HappyPath(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setMissing(rb) - s := newTestState() - - if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - if countMethod(rb, "createNewStickerSet") != 1 { - t.Fatalf("methods = %v, want one createNewStickerSet", methodsSent(rb)) - } - pack, found := loadPack(t, s) - if !found { - t.Fatal("no pack record after a successful /newpack") - } - if pack.Pending { - t.Error("record is still Pending after success") - } - if pack.Name != testSet || pack.Count != 1 { - t.Errorf("pack = %+v, want name %q and count 1", pack, testSet) - } - if !strings.Contains(rb.LastSent().Text(), shareLink(testSet)) { - t.Errorf("reply = %q, want the share link", rb.LastSent().Text()) - } -} - -// The quota is the create-only write itself: there is no separate counter, so a -// second /newpack must lose on PutVersioned and never reach the API. -func TestNewPack_SecondPackRefusedWithoutAPICall(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - s := newTestState() - seedPack(t, s, 5) - - if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack another Another", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - if countMethod(rb, "createNewStickerSet") != 0 || countMethod(rb, "getStickerSet") != 0 { - t.Errorf("methods = %v, want no sticker-set API calls", methodsSent(rb)) - } - text := rb.LastSent().Text() - if !strings.Contains(text, "mypack") || !strings.Contains(text, "/delpack") { - t.Errorf("reply = %q, want it to name the existing slug and /delpack", text) - } -} - -// Re-running the same command after an interruption must complete the pack, -// not report the slug taken. This is what keeps a crash from stranding a -// permanent URL. -// An interrupted attempt whose set DOES exist is refused, not resumed. -// -// This used to adopt. Adoption is gone: no local fact can prove a set belongs -// to the caller, because the store holding that fact is exactly what a restart -// on the in-memory backend erases while the packs survive. The cost is that a -// crash between creation and commit strands the set; the benefit is that the -// same evidence cannot be manufactured by an attacker. -func TestNewPack_InterruptedAttemptWithLiveSetIsRefused(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setExists(rb) - s := newTestState() - ctx := context.Background() - - seedInterrupted(t, s, "mypack", testSet) - - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - if countMethod(rb, "createNewStickerSet") != 0 { - t.Errorf("methods = %v, want no create — a set already exists under that name", methodsSent(rb)) - } - if got := rb.LastSent().Text(); !strings.Contains(got, slugTaken) { - t.Errorf("reply = %q, want the name-taken refusal", got) - } - if pack, found := loadPack(t, s); found && !pack.Pending { - t.Errorf("adopted the existing set: %+v", pack) - } - // Nothing left behind: a pending record naming a set the caller may not own - // is a delete primitive, since /delpack deletes by set name. - if _, found := loadPack(t, s); found { - t.Error("refusal kept the intent — /delpack could then aim it at that set") - } - if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { - t.Error("refusal kept the reservation") - } -} - -// A pending record under a *different* slug whose set exists is likewise not -// adopted; the caller proceeds under the name they asked for, and the stranded -// name stays reserved because a set really does occupy it. -func TestNewPack_DifferentSlugDoesNotAdoptExistingSet(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setExists(rb) - s := newTestState() - ctx := context.Background() - - seedInterrupted(t, s, "oldslug", testSet) - - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newslug New", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - if pack, found := loadPack(t, s); found && pack.Slug == "oldslug" && !pack.Pending { - t.Errorf("adopted the old set: %+v", pack) - } - // The old name stays claimed: a set exists under it, so it is not free. - if _, held, _ := getSlugReservation(ctx, s.slugs, "oldslug"); !held { - t.Error("released a name that still has a set behind it") - } -} - -// Same shape, but nothing was created under the old name: the record is free to -// take over. -func TestNewPack_DifferentSlugReplacesDeadIntent(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setMissing(rb) - s := newTestState() - - seedInterrupted(t, s, "oldslug", "oldslug_by_testbot") - - if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - if countMethod(rb, "createNewStickerSet") != 1 { - t.Fatalf("methods = %v, want one create", methodsSent(rb)) - } - pack, _ := loadPack(t, s) - if pack.Slug != "mypack" || pack.Pending { - t.Errorf("pack = %+v, want a confirmed mypack record", pack) - } - // The old name had nothing behind it, so it must return to the pool. - // Without this assertion the test was named for a behaviour it never - // checked: every abandoned attempt would quietly shrink the namespace. - if _, held, _ := getSlugReservation(context.Background(), s.slugs, "oldslug"); held { - t.Error("the dead name stayed reserved with no set behind it") - } -} - -// An unclassifiable getStickerSet failure means "unknown". Guessing either way -// is what strands slugs or orphans sets, so the handler must change nothing. -func TestNewPack_UnknownLookupErrorAborts(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) - s := newTestState() - - if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - if countMethod(rb, "createNewStickerSet") != 0 { - t.Errorf("methods = %v, want no create after an unknown error", methodsSent(rb)) - } - // The intent and reservation must SURVIVE. "Unknown" means the set may - // exist; destroying either here is what permanently strands a slug. Keeping - // them is what makes re-running the same command recover. - pack, found := loadPack(t, s) - if !found || !pack.Pending { - t.Errorf("intent = (%+v, found=%v), want it kept and still pending for re-run recovery", pack, found) - } - if _, held, _ := getSlugReservation(context.Background(), s.slugs, "mypack"); !held { - t.Error("reservation dropped on an unknown error; another user could take the name while the set may exist") - } -} - -// Telegram itself refusing the name — NOT the "another user of this bot holds -// it" case, which the reservation now settles before any API call (see -// TestNewPack_ForeignReservationRefusedBeforeAnyAPICall). -// -// The reachable path here is a short name Telegram still reserves after a -// delete (plan R11): our reservation is free, GetStickerSet says missing, and -// createNewStickerSet refuses. A classified refusal proves nothing was created, -// so both the intent and the reservation are released for a retry. -func TestNewPack_OccupiedSlugDropsIntent(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setMissing(rb) - rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_OCCUPIED") - s := newTestState() - - if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - if _, found := loadPack(t, s); found { - t.Error("intent survived a rejected create") - } - if _, held, _ := getSlugReservation(context.Background(), s.slugs, "mypack"); held { - t.Error("reservation survived a classified refusal; the name would be held with no set behind it") - } - if !strings.Contains(rb.LastSent().Text(), "taken") { - t.Errorf("reply = %q, want the slug-taken message", rb.LastSent().Text()) - } -} - -func TestNewPack_ValidatesInput(t *testing.T) { - cases := []struct { - name string - text string - }{ - {"no arguments", "/newpack"}, - {"slug only", "/newpack mypack"}, - {"leading digit", "/newpack 1pack Title"}, - {"double underscore", "/newpack my__pack Title"}, - {"trailing underscore", "/newpack mypack_ Title"}, - {"too short", "/newpack ab Title"}, - {"title too long", "/newpack mypack " + strings.Repeat("x", maxTitleLen+1)}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setMissing(rb) - s := newTestState() - - if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply(tc.text, otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - if countMethod(rb, "createNewStickerSet") != 0 { - t.Errorf("methods = %v, want rejection before any create", methodsSent(rb)) - } - if _, found := loadPack(t, s); found { - t.Error("a rejected /newpack wrote a record") - } - }) - } -} - -func TestMyPack_MakesNoAPICalls(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 7) - - upd := testutil.NewPrivateMessage(testUser, "/mypack") - if err := s.handleMyPack(context.Background(), rb.Bot, upd); err != nil { - t.Fatalf("handleMyPack: %v", err) - } - - for _, call := range rb.Sent() { - if call.Method != "sendMessage" { - t.Errorf("unexpected API call %q; /mypack must read only the store", call.Method) - } - } - text := rb.LastSent().Text() - for _, want := range []string{"My Pack", "mypack", "7", shareLink(testSet)} { - if !strings.Contains(text, want) { - t.Errorf("reply %q missing %q", text, want) - } - } -} - -// A stranded attempt is shown, not hidden: it blocks /newpack, and re-running -// the same command is what clears it. -func TestMyPack_ShowsPendingMarker(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - pack := seedPack(t, s, 0) - pack.Pending = true - if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil { - t.Fatalf("seed pending: %v", err) - } - - if err := s.handleMyPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/mypack")); err != nil { - t.Fatalf("handleMyPack: %v", err) - } - if !strings.Contains(rb.LastSent().Text(), "incomplete") { - t.Errorf("reply = %q, want the incomplete marker", rb.LastSent().Text()) - } -} - -func TestMyPack_NoPack(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - - if err := s.handleMyPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/mypack")); err != nil { - t.Fatalf("handleMyPack: %v", err) - } - if !strings.Contains(rb.LastSent().Text(), "/newpack") { - t.Errorf("reply = %q, want it to point at /newpack", rb.LastSent().Text()) - } -} - -// The link cannot follow a rename, so the reply has to name the only route to a -// different one — otherwise the user is left at a dead end. -func TestRenamePack_NamesTheURLChangeRoute(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - seedPack(t, s, 4) - - if err := s.handleRenamePack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/renamepack Better Name")); err != nil { - t.Fatalf("handleRenamePack: %v", err) - } - if countMethod(rb, "setStickerSetTitle") != 1 { - t.Fatalf("methods = %v, want one setStickerSetTitle", methodsSent(rb)) - } - text := rb.LastSent().Text() - for _, want := range []string{"Better Name", shareLink(testSet), "/delpack", "/newpack"} { - if !strings.Contains(text, want) { - t.Errorf("reply %q missing %q", text, want) - } - } - pack, _ := loadPack(t, s) - if pack.Title != "Better Name" { - t.Errorf("Title = %q, want the new title committed", pack.Title) - } -} - -func TestRenamePack_NoPack(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - - if err := s.handleRenamePack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/renamepack Whatever")); err != nil { - t.Fatalf("handleRenamePack: %v", err) - } - if countMethod(rb, "setStickerSetTitle") != 0 { - t.Errorf("methods = %v, want no API call", methodsSent(rb)) - } -} - -// Anonymous senders are refused before any store or API access, on every -// command. Telegram gives every anonymous admin the same From.ID, so without -// this they would all share one pack — and under one-pack-per-user, the first -// one to run /newpack would own it and block the rest. -func TestHandlers_RefuseAnonymousSenders(t *testing.T) { - handlers := map[string]struct { - text string - run func(*state, context.Context, *bot.Bot, *models.Update) error - }{ - "newpack": {"/newpack mypack My Pack", (*state).handleNewPack}, - "mypack": {"/mypack", (*state).handleMyPack}, - "addsticker": {"/addsticker 😂", (*state).handleAddSticker}, - "delsticker": {"/delsticker", (*state).handleDelSticker}, - "editsticker": {"/editsticker 😂", (*state).handleEditSticker}, - "ordersticker": {"/ordersticker 0", (*state).handleOrderSticker}, - "renamepack": {"/renamepack Title", (*state).handleRenamePack}, - "delpack": {"/delpack", (*state).handleDelPack}, - "setpackicon": {"/setpackicon", (*state).handleSetPackIcon}, - } - for name, h := range handlers { - t.Run(name, func(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setMissing(rb) - s := newTestState() - seedPack(t, s, 3) - - upd := stickerReply(h.text, testSet) - upd.Message.SenderChat = &models.Chat{ID: -100} - - if err := h.run(s, context.Background(), rb.Bot, upd); err != nil { - t.Fatalf("%s: %v", name, err) - } - for _, call := range rb.Sent() { - if call.Method != "sendMessage" { - t.Errorf("%s called %q for an anonymous sender", name, call.Method) - } - } - if !strings.Contains(rb.LastSent().Text(), "personal account") { - t.Errorf("%s reply = %q, want the anonymous-sender refusal", name, rb.LastSent().Text()) - } - }) - } -} - -// seedInterrupted recreates the state a crashed /newpack leaves behind: a -// pending pack record AND the global name reservation that always precedes it. -// Seeding the record alone would build a state production cannot reach. -func seedInterrupted(t *testing.T, s *state, slug, setName string) { - t.Helper() - ctx := context.Background() - pending := Pack{Slug: slug, Name: setName, Title: "Old", OwnerID: testUser, Pending: true} - if err := s.store.Put(ctx, packKey(testUser), pending); err != nil { - t.Fatalf("seed pending: %v", err) - } - if err := s.slugs.Put(ctx, slugKey(slug), - SlugReservation{Slug: slug, OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil { - t.Fatalf("seed reservation: %v", err) - } -} - -// The pack-takeover regression. Share links are public, so any user can read a -// pack's slug off t.me and try to claim it. Before the global reservation, an -// attacker with no pack of their own reached createOrAdopt, found the victim's -// set existing, and adopted it — after which /delpack destroyed the victim's -// pack. -// -// The attacker must be refused, and must leave no trace: no adoption, no record -// of their own, and the victim's reservation untouched. -func TestNewPack_CannotSeizeAnotherUsersPack(t *testing.T) { - const victim, attacker = int64(1), int64(2) - - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setExists(rb) // the victim's set resolves - s := newTestState() - - ctx := context.Background() - if err := s.store.Put(ctx, packKey(victim), - Pack{Slug: "victimpack", Name: "victimpack_by_testbot", Title: "Victim", OwnerID: victim, Count: 9}); err != nil { - t.Fatalf("seed victim pack: %v", err) - } - if err := s.slugs.Put(ctx, slugKey("victimpack"), - SlugReservation{Slug: "victimpack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil { - t.Fatalf("seed victim reservation: %v", err) - } - - upd := stickerReply("/newpack victimpack Mine Now", otherSet) - upd.Message.From.ID = attacker - if err := s.handleNewPack(ctx, rb.Bot, upd); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - if !strings.Contains(rb.LastSent().Text(), "taken") { - t.Errorf("reply = %q, want the name refused as taken", rb.LastSent().Text()) - } - if got, found, _ := getPack(ctx, s.store, attacker); found { - t.Errorf("attacker now holds a pack record %+v — takeover succeeded", got) - } - held, _, _ := getSlugReservation(ctx, s.slugs, "victimpack") - if held.OwnerID != victim { - t.Errorf("reservation owner = %d, want the victim (%d)", held.OwnerID, victim) - } - // The victim's own record must be exactly as it was. - pack, found, _ := getPack(ctx, s.store, victim) - if !found || pack.Count != 9 || pack.Title != "Victim" { - t.Errorf("victim pack = (%+v, found=%v), want it untouched", pack, found) - } -} - -// The same protection has to hold for the resume path: a pending record whose -// slug is reserved by somebody else must not adopt either. -func TestNewPack_StaleIntentCannotAdoptForeignName(t *testing.T) { - const victim, attacker = int64(1), int64(2) - - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setExists(rb) - s := newTestState() - ctx := context.Background() - - // The attacker holds a pending record naming the victim's set, but the - // reservation belongs to the victim. - if err := s.store.Put(ctx, packKey(attacker), - Pack{Slug: "victimpack", Name: "victimpack_by_testbot", Title: "Old", OwnerID: attacker, Pending: true}); err != nil { - t.Fatalf("seed attacker intent: %v", err) - } - if err := s.slugs.Put(ctx, slugKey("victimpack"), - SlugReservation{Slug: "victimpack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil { - t.Fatalf("seed victim reservation: %v", err) - } - - upd := stickerReply("/newpack otherslug Other", otherSet) - upd.Message.From.ID = attacker - if err := s.handleNewPack(ctx, rb.Bot, upd); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - pack, _, _ := getPack(ctx, s.store, attacker) - if pack.Name == "victimpack_by_testbot" && !pack.Pending { - t.Errorf("attacker adopted the victim's set via the stale-intent path: %+v", pack) - } - held, _, _ := getSlugReservation(ctx, s.slugs, "victimpack") - if held.OwnerID != victim { - t.Errorf("reservation owner = %d, want the victim (%d)", held.OwnerID, victim) - } -} - -// F5's replacement: a name held by another user is now refused by the -// reservation, before any API call. The previous test of this name stubbed a -// combination (set missing + PACK_SHORT_NAME_OCCUPIED) that cannot occur for a -// set another user holds, so it never covered this case. -func TestNewPack_ForeignReservationRefusedBeforeAnyAPICall(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - s := newTestState() - ctx := context.Background() - - if err := s.slugs.Put(ctx, slugKey("mypack"), - SlugReservation{Slug: "mypack", OwnerID: 999, CreatedAt: fixedNow.UnixMilli()}); err != nil { - t.Fatalf("seed reservation: %v", err) - } - - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - for _, call := range rb.Sent() { - if call.Method != "sendMessage" && call.Method != "getMe" { - t.Errorf("called %q for a name held by another user; want refusal before any sticker API call", call.Method) - } - } - if !strings.Contains(rb.LastSent().Text(), "taken") { - t.Errorf("reply = %q, want the taken refusal", rb.LastSent().Text()) - } -} - -// The name-burning regression. Reservations are permanent and global, so -// writing one before establishing the caller is even entitled to a pack turned -// every refused /newpack into a free, unlimited denial primitive: no API call, -// no cost, and the name is gone for everyone else forever. -func TestNewPack_RefusedRunsClaimNoNames(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setMissing(rb) - s := newTestState() - ctx := context.Background() - - seedPack(t, s, 3) // the caller already has a finished pack - - for _, name := range []string{"memes", "funny", "cats"} { - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack "+name+" x", otherSet)); err != nil { - t.Fatalf("handleNewPack(%s): %v", name, err) - } - if !strings.Contains(rb.LastSent().Text(), "already have a pack") { - t.Fatalf("reply = %q, want the already-have-a-pack refusal", rb.LastSent().Text()) - } - if _, held, _ := getSlugReservation(ctx, s.slugs, name); held { - t.Errorf("refused /newpack claimed %q — every other user is now permanently denied that name", name) - } - } - - // Only the real pack's own name is reserved. - keys, err := s.slugs.List(ctx, slugPrefix) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(keys) != 1 { - t.Errorf("reservations = %d (%v), want exactly the one backing the real pack", len(keys), keys) - } -} - -// A name is also not burned when the *set name* is unusable, or when anything -// else makes the command bail after reserving. -func TestNewPack_ReservationReleasedWhenClaimFails(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setMissing(rb) - rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_INVALID") - s := newTestState() - ctx := context.Background() - - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { - t.Error("reservation survived a refusal that proves nothing was created") - } - if _, found := loadPack(t, s); found { - t.Error("intent survived a refusal that proves nothing was created") - } -} - -// These two are the only coverage of the `created` flag itself. -// -// A predecessor named ...ResumedReservationSurvivesABail sat here and did not -// test its name: its bail happened after claimSlug, which never consults the -// flag, so it passed with the whole distinction deleted. It duplicated -// TestNewPack_UnknownLookupErrorAborts and has been removed. -// -// Both drive a bail *inside* claimSlug, which is the single place handleNewPack -// consults `created`. The other reservation tests bail later — in createOrAdopt -// — where a different mechanism (createRefused) does the releasing, so they -// pass with the `created` guard removed entirely and cannot pin it. -// -// Reaching claimSlug's bail takes a pending record under a *different* slug: -// that sends claimSlug into resolveStaleIntent, which gives up when it cannot -// establish what happened to the old set. -func seedStaleIntentBail(t *testing.T, rb *testutil.RecordingBot, s *state) { - t.Helper() - stubBotIdentity(rb) - // Unknown failure probing the *old* set: resolveStaleIntent refuses to - // guess, so handleNewPack bails holding whatever reserveSlug just did. - rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) - seedInterrupted(t, s, "oldname", otherSet) -} - -// Direction 1: a name this invocation reserved must not survive the bail. -// Without the release, every refused attempt burns a global name for everyone. -func TestNewPack_FreshReservationReleasedWhenClaimBails(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - ctx := context.Background() - seedStaleIntentBail(t, rb, s) - - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newname New Pack", testSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - if _, held, _ := getSlugReservation(ctx, s.slugs, "newname"); held { - t.Error("a name reserved by this invocation survived its bail — the name is now permanently denied to every other user, with no pack behind it") - } - if _, held, _ := getSlugReservation(ctx, s.slugs, "oldname"); !held { - t.Error("the pre-existing reservation was collateral damage") - } -} - -// Direction 2: a name the caller already held must survive the bail. Releasing -// it would hand a live claim to the next user to ask while the set may exist. -func TestNewPack_ResumedReservationNotReleasedWhenClaimBails(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - ctx := context.Background() - seedStaleIntentBail(t, rb, s) - - // The caller already holds "newname" from an earlier run, so reserveSlug - // resumes it rather than creating it. - if err := s.slugs.Put(ctx, slugKey("newname"), - SlugReservation{Slug: "newname", OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil { - t.Fatalf("seed prior reservation: %v", err) - } - - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newname New Pack", testSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - if _, held, _ := getSlugReservation(ctx, s.slugs, "newname"); !held { - t.Error("a reservation that predates this command was released on its bail — another user can now claim a name whose set may already exist") - } -} - -// A reservation is only proof of ownership while it outlives the sets it -// guards, and it does not: reservations live in our store, packs live at -// Telegram, and a restart on the in-memory backend wipes the former while every -// pack survives. This is the takeover of TestNewPack_CannotSeizeAnotherUsersPack -// replayed against an empty store, which is exactly what the attacker gets for -// free after any wipe. -// -// The set existing under a name this invocation has only just claimed proves -// the set is somebody else's: a real interrupted attempt reserved the name -// before creating the set, so it always finds its own reservation waiting. -func TestNewPack_WipedStoreCannotAdoptSurvivingPack(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setExists(rb) // the victim's pack outlived our store - s := newTestState() - ctx := context.Background() - - // Deliberately empty: no reservations, no pack records, nothing. - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - if got := rb.LastSent().Text(); !strings.Contains(got, slugTaken) { - t.Errorf("reply = %q, want the name-taken refusal", got) - } - if pack, found := loadPack(t, s); found && !pack.Pending { - t.Errorf("adopted a pack that survived the wipe: %+v — /delpack would now destroy its real owner's set", pack) - } - for _, call := range rb.Sent() { - if call.Method == "createNewStickerSet" || call.Method == "setStickerSetTitle" { - t.Errorf("refused adoption still called %s", call.Method) - } - } - // The refusal must not burn the name either: the set's real owner has to be - // able to re-register it after the same wipe. - if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { - t.Error("the refused attempt kept the reservation, denying the name to the set's actual owner") - } -} - -// The two-command takeover: no crash, no store error, two ordinary /newpack -// calls, and the attacker used to end up owning a stranger's pack. -// -// The first command's GetStickerSet is inconclusive (429, 5xx, a deadline), so -// the module correctly keeps the intent and the reservation — re-running is how -// a real user recovers. But that turned the attacker's *fresh* reservation into -// a *resumed* one, which defeated the per-invocation guard that was supposed to -// make adoption safe. The second identical command then adopted. -// -// The guard is gone; refusing outright is what closes this. The starting state -// is an empty store, which is what a restart on the in-memory backend leaves -// behind while every pack at Telegram survives. -func TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver(t *testing.T) { - s := newTestState() - ctx := context.Background() - - rb1 := testutil.NewRecordingBot(t) - stubBotIdentity(rb1) - rb1.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`) - if err := s.handleNewPack(ctx, rb1.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil { - t.Fatalf("first /newpack: %v", err) - } - - // Fresh bot: Reset() deliberately keeps registered failures. - rb2 := testutil.NewRecordingBot(t) - stubBotIdentity(rb2) - setExists(rb2) - if err := s.handleNewPack(ctx, rb2.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil { - t.Fatalf("second /newpack: %v", err) - } - - if pack, found := loadPack(t, s); found && !pack.Pending { - t.Errorf("TAKEOVER: caller now owns %+v and can /delpack a set they never created", pack) - } - if got := rb2.LastSent().Text(); !strings.Contains(got, slugTaken) { - t.Errorf("reply = %q, want the name-taken refusal", got) - } -} - -// A pending record is not authority to delete a set. -// -// /newpack writes its intent before Telegram is called, so anyone can produce a -// pending record naming any set. DeleteStickerSet is keyed by set name and -// Telegram authorises it for every set this bot created — so confirming a -// delete from a pending record would let one user destroy another's pack, with -// no adoption needed at all. -func TestDelPack_PendingRecordDeletesNothingAtTelegram(t *testing.T) { - rb := testutil.NewRecordingBot(t) - s := newTestState() - ctx := context.Background() - - // What a post-wipe /newpack against a stranger's slug leaves behind. - seedInterrupted(t, s, "mypack", testSet) - - if err := s.handleDelPack(ctx, rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil { - t.Fatalf("handleDelPack: %v", err) - } - - if n := countMethod(rb, "deleteStickerSet"); n != 0 { - t.Errorf("deleteStickerSet calls = %d, want 0 — an unconfirmed record must not reach Telegram", n) - } - // It should still clean up locally, so the user is not wedged. - if _, found := loadPack(t, s); found { - t.Error("local record survived, so /newpack stays blocked") - } - if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held { - t.Error("name stayed reserved with nothing behind it") - } -} - -// releaseSlug verifies the holder itself rather than trusting its callers. -// -// A bare delete-by-name is a cross-user primitive: it is reached from seven -// call sites, and one of them getting the owner wrong would hand a live name -// away while the set still exists. -func TestReleaseSlug_RefusesANameHeldBySomeoneElse(t *testing.T) { - s := newTestState() - ctx := context.Background() - const holder, caller = int64(1), int64(2) - - if err := s.slugs.Put(ctx, slugKey("mypack"), - SlugReservation{Slug: "mypack", OwnerID: holder, CreatedAt: fixedNow.UnixMilli()}); err != nil { - t.Fatalf("seed: %v", err) - } - - s.releaseSlug(ctx, caller, "mypack") - - held, found, err := getSlugReservation(ctx, s.slugs, "mypack") - if err != nil { - t.Fatalf("read back: %v", err) - } - if !found || held.OwnerID != holder { - t.Error("a non-holder released the name; the holder's set is still live and the name is now claimable") - } -} - -// The release must survive a cancelled request context. -// -// Both the ownership read and the delete run on a detached context. When only -// the delete was detached, a shutdown mid-handler failed the read and returned -// early — leaving a reservation with no pack and no set behind it, which no -// code path can reach again. -func TestReleaseSlug_CompletesOnACancelledContext(t *testing.T) { - s := newTestState() - seed := context.Background() - - if err := s.slugs.Put(seed, slugKey("mypack"), - SlugReservation{Slug: "mypack", OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil { - t.Fatalf("seed: %v", err) - } - - // The in-memory backend ignores context entirely, so cancelling one proves - // nothing against it — this assertion passed whether or not the code - // detached until the store was made to honour cancellation. - s.slugs = ctxHonouringSlugs{inner: s.slugs} - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // as if SIGTERM landed mid-handler - - s.releaseSlug(ctx, testUser, "mypack") - - if _, held, _ := getSlugReservation(seed, s.slugs, "mypack"); held { - t.Error("reservation survived a cancelled release; the name is stranded permanently") - } -} - -// ctxHonouringSlugs makes a SlugStore respect context cancellation, which the -// in-memory backend does not. Needed to test anything about detached contexts: -// against the bare memory store the operation completes either way. -type ctxHonouringSlugs struct{ inner SlugStore } - -func (c ctxHonouringSlugs) Get(ctx context.Context, id string) (SlugReservation, int64, error) { - if err := ctx.Err(); err != nil { - return SlugReservation{}, 0, err - } - return c.inner.Get(ctx, id) -} - -func (c ctxHonouringSlugs) Put(ctx context.Context, id string, val SlugReservation) error { - if err := ctx.Err(); err != nil { - return err - } - return c.inner.Put(ctx, id, val) -} - -func (c ctxHonouringSlugs) PutVersioned(ctx context.Context, id string, expectedVersion int64, val SlugReservation) error { - if err := ctx.Err(); err != nil { - return err - } - return c.inner.PutVersioned(ctx, id, expectedVersion, val) -} - -func (c ctxHonouringSlugs) Delete(ctx context.Context, id string) error { - if err := ctx.Err(); err != nil { - return err - } - return c.inner.Delete(ctx, id) -} - -func (c ctxHonouringSlugs) List(ctx context.Context, prefix string) ([]string, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - return c.inner.List(ctx, prefix) -} - -// A record that goes away takes its outstanding confirmation with it. -// -// The callback re-checks authority anyway, so this is defence in depth — but an -// unpinned guard is how the last one rotted into a blocklist unnoticed. -func TestDropPackRecord_ClearsAnOutstandingConfirmation(t *testing.T) { - s := newTestState() - ctx := context.Background() - - seedPack(t, s, 3) - seedPendingDelete(t, s, nil) - - s.dropPackRecord(ctx, testUser) - - if _, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)); err == nil { - t.Error("confirmation outlived the record that authorised it") - } -} - -// Resuming an interrupted attempt must use the title from the command the user -// just sent, not the one stored by the attempt that failed. -// -// The stored record was returned verbatim, so a retyped title was silently -// discarded and the success message quoted the old one — "/newpack mypack New" -// answering "Created Old." -func TestNewPack_ResumeUsesTheTitleJustTyped(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) - setMissing(rb) // nothing was created last time, so this run creates it - s := newTestState() - ctx := context.Background() - - seedInterrupted(t, s, "mypack", testSet) // stored title is "Old" - - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack Brand New Title", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - pack, found := loadPack(t, s) - if !found { - t.Fatal("no record after a resumed create") - } - if pack.Title != "Brand New Title" { - t.Errorf("stored title = %q, want the one just typed", pack.Title) - } - if got := rb.LastSent().Text(); !strings.Contains(got, "Brand New Title") { - t.Errorf("reply = %q, want it to quote the title just typed", got) - } - for _, call := range rb.Sent() { - if call.Method == "createNewStickerSet" && call.Form["title"] != "Brand New Title" { - t.Errorf("created with title %q, want the one just typed", call.Form["title"]) - } - } -} - -// Resuming must not re-derive the set name. -// -// Pack.Name is built from the bot's username, which can change at BotFather. -// The stored name identifies the set the interrupted attempt may already have -// created; refreshing it from the current username would repoint the record at -// a name nothing exists under, orphaning that set and aiming every later -// command at the wrong one. ownsSet documents the same rule. -func TestNewPack_ResumeKeepsTheStoredSetName(t *testing.T) { - rb := testutil.NewRecordingBot(t) - stubBotIdentity(rb) // resolves to "testbot" - setMissing(rb) - s := newTestState() - ctx := context.Background() - - // The earlier attempt ran while the bot was called something else. - const legacySet = "mypack_by_oldbot" - seedInterrupted(t, s, "mypack", legacySet) - - if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil { - t.Fatalf("handleNewPack: %v", err) - } - - pack, found := loadPack(t, s) - if !found { - t.Fatal("no record after resume") - } - if pack.Name != legacySet { - t.Errorf("set name = %q, want the stored %q — the earlier attempt's set is now orphaned", pack.Name, legacySet) - } -} diff --git a/internal/modules/sticker/pack_test.go b/internal/modules/sticker/pack_test.go deleted file mode 100644 index 9c2337c..0000000 --- a/internal/modules/sticker/pack_test.go +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index bc2acd9..0000000 --- a/internal/modules/sticker/pending_delete.go +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 671808f..0000000 --- a/internal/modules/sticker/photo.go +++ /dev/null @@ -1,86 +0,0 @@ -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/resolve.go b/internal/modules/sticker/resolve.go deleted file mode 100644 index 87d0c09..0000000 --- a/internal/modules/sticker/resolve.go +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index dad8b28..0000000 --- a/internal/modules/sticker/resolve_test.go +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index 6f74b6c..0000000 --- a/internal/modules/sticker/sender.go +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 79ac074..0000000 --- a/internal/modules/sticker/sender_test.go +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 170da5b..0000000 --- a/internal/modules/sticker/setname.go +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index c0c1769..0000000 --- a/internal/modules/sticker/setname_test.go +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index b6ad2b0..0000000 --- a/internal/modules/sticker/setpackicon.go +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index eb1901e..0000000 --- a/internal/modules/sticker/state.go +++ /dev/null @@ -1,127 +0,0 @@ -package sticker - -import ( - "context" - "errors" - "strconv" - "strings" - "time" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/miti99bot/internal/keylock" - "github.com/tiennm99/miti99bot/internal/log" - "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" -) - -const ( - // handlerTimeout bounds every handler in this module. - // - // Nothing else does. The bot registers handlers with - // bot.WithNotAsyncHandlers() and one worker, so updates run inline on the - // polling goroutine, and the handler context is rootCtx, which carries no - // deadline — the only remaining ceiling is the library's shared 60s HTTP - // client, per call. A handler making several sequential API calls could - // therefore freeze the bot for every user for minutes. - handlerTimeout = 10 * time.Second - - // commitTimeout bounds a post-success store write. These run on a context - // detached from the request (see commitContext), so they need their own. - commitTimeout = 5 * time.Second -) - -// state holds everything the handlers share. Mirrors the shape used by coin -// and stock: a typed store, a second typed view for pending actions, the -// per-user lock map, and an injectable clock. -type state struct { - store PackStore - pending PendingDeleteStore - slugs SlugStore - resolver usernameResolver - locks keylock.Map - nowFn func() time.Time -} - -func (s *state) now() time.Time { - if s.nowFn != nil { - return s.nowFn() - } - return time.Now().UTC() -} - -// commitContext detaches a store write from the request context. -// -// rootCtx is cancelled by SIGTERM, and a handler is most likely to be mid-flight -// exactly when a deploy lands. A commit that records a completed Telegram-side -// action must not be lost because the process is shutting down: at that point -// the set already exists and only the bot's memory of it is at stake. -func commitContext(ctx context.Context) (context.Context, context.CancelFunc) { - return context.WithTimeout(context.WithoutCancel(ctx), commitTimeout) -} - -// handlerContext applies the module-wide deadline. -func handlerContext(ctx context.Context) (context.Context, context.CancelFunc) { - return context.WithTimeout(ctx, handlerTimeout) -} - -// mediaContext bounds the download-and-upload leg of a handler, reserving the -// tail of the parent's budget for what comes after it. -// -// This module is the only one that spends most of its deadline before it has -// anything to say: a photo /newpack downloads, resamples and re-uploads before -// it calls CreateNewStickerSet. Run on the bare handler context, a slow link -// exhausted the whole 10s inside the media leg, and the reply — including the -// error reply explaining what went wrong — was then sent on a dead context, so -// the user saw nothing at all. chathelper.FetchContext is the existing fix for -// exactly this, already used by coin, gold, stock and monkeyd. -func mediaContext(ctx context.Context) (context.Context, context.CancelFunc) { - return chathelper.FetchContext(ctx) -} - -// lockUser serialises a user's mutations. -// -// Nothing contends for it today: the map is state-local to this module, the bot -// dispatches inline with a single worker, and this module registers neither a -// cron nor a command hook. An earlier version of this comment claimed the cron -// scheduler and stats hook contended here — they do not, and a wrong reason for -// a right guard is worse than none, because the next reader trusts it. -// -// It stays because every mutation here is a read-modify-write, which is wrong -// the moment dispatch stops being serial, and an uncontended mutex costs -// nothing. Note that releaseSlug's read-then-delete is not atomic under this -// lock either; that is safe only while dispatch is serial. -func (s *state) lockUser(ownerID int64) func() { - return s.locks.Acquire(strconv.FormatInt(ownerID, 10)) -} - -// commandArgs returns the whitespace-separated arguments after the command. -func commandArgs(msg *models.Message) []string { - return strings.Fields(chathelper.ArgAfterCommand(msg.Text)) -} - -// commandArgText returns the raw text after the command, trimmed. -func commandArgText(msg *models.Message) string { - return chathelper.ArgAfterCommand(msg.Text) -} - -// reply sends text as a reply to msg. -func reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error { - return chathelper.Reply(ctx, b, msg, text) -} - -// replyErr turns a handler error into a reply. -// -// A userError is shown verbatim — it was written for the user. Anything else is -// logged and replaced with a generic line: internal errors can carry a download -// URL with the bot token in it, and this module must never echo one. -func replyErr(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error { - var ue userError - if errors.As(err, &ue) { - return reply(ctx, b, msg, ue.msg) - } - log.Error(op, "err", err) - return reply(ctx, b, msg, genericFailure) -} - -const genericFailure = "Something went wrong. Try again in a moment." diff --git a/internal/modules/sticker/sticker.go b/internal/modules/sticker/sticker.go deleted file mode 100644 index e6afab7..0000000 --- a/internal/modules/sticker/sticker.go +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 4dab023..0000000 --- a/internal/modules/sticker/sticker_handlers.go +++ /dev/null @@ -1,224 +0,0 @@ -package sticker - -import ( - "context" - "fmt" - "strconv" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/miti99bot/internal/log" -) - -// maxStickersPerPack is Telegram's documented ceiling for a regular set. It is -// not enforced locally — the server is the authority and a local copy would go -// stale — but it is quoted back to the user when the server refuses. -const maxStickersPerPack = 120 - -// handleAddSticker adds the replied sticker (or, from Phase 5, photo) to the -// caller's pack. -func (s *state) handleAddSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - msg := update.Message - ownerID, err := senderID(msg) - if err != nil { - return reply(ctx, b, msg, senderRefusal) - } - - // Every argument is an emoji: with no pack token to disambiguate, a stray - // word fails here rather than being silently read as something else. - emoji, err := parseEmoji(commandArgs(msg)) - if err != nil { - return replyErr(ctx, b, msg, "sticker_addsticker_emoji", err) - } - - pack, found, err := getPack(ctx, s.store, ownerID) - if err != nil { - log.Error("sticker_addsticker_load", "err", err) - return reply(ctx, b, msg, genericFailure) - } - // Unlike resolveOwned's deliberately uniform refusal, this one is specific: - // it answers only "do *you* have a pack", about the caller's own state, and - // so discloses nothing about anyone else. - if !found { - return reply(ctx, b, msg, noPackYet) - } - if pack.Pending { - return reply(ctx, b, msg, noPackYet+pendingMarker) - } - - source, err := s.resolveSource(ctx, b, ownerID, msg) - if err != nil { - return replyErr(ctx, b, msg, "sticker_addsticker_source", err) - } - - // Precedence: explicit args, then the replied sticker's own emoji, then the - // default. Telegram requires at least one. - if len(emoji) == 0 { - emoji = source.emoji - } - if len(emoji) == 0 { - emoji = []string{defaultEmoji} - } - - defer s.lockUser(ownerID)() - - _, err = b.AddStickerToSet(ctx, &bot.AddStickerToSetParams{ - UserID: ownerID, // always the caller: a non-owner never reaches this call - Name: pack.Name, - Sticker: models.InputSticker{ - Sticker: source.fileID, - Format: stickerFormatStatic, - EmojiList: emoji, - }, - }) - if err != nil { - if isStickerSetMissing(err) { - s.dropPackRecord(ctx, ownerID) - } - return replyAPIError(ctx, b, msg, "sticker_addsticker", err) - } - - updated, err := s.adjustCount(ctx, ownerID, +1) - if err != nil { - // The sticker is already in the set; only our count is stale. - log.Error("sticker_addsticker_commit", "err", err) - updated = pack - updated.Count++ - } - return reply(ctx, b, msg, fmt.Sprintf("Added to %s (%d stickers).\n%s", - updated.Title, updated.Count, shareLink(updated.Name))) -} - -// handleDelSticker removes the replied sticker from the caller's pack. -// -// It deliberately does *not* probe afterwards to see whether the set survived. -// Whether removing the last sticker also destroys the set is undocumented, and -// an earlier design that probed would have deleted the pack record whenever the -// probe merely failed — so a 429, a DNS blip, or a SIGTERM during a routine -// delete would erase the only record of a live pack. Deleting the record needs -// a positive signal, and the next command's STICKERSET_INVALID is one. -func (s *state) handleDelSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - msg := update.Message - ownerID, err := senderID(msg) - if err != nil { - return reply(ctx, b, msg, senderRefusal) - } - - owned, err := s.resolveOwned(ctx, msg, ownerID) - if err != nil { - return replyErr(ctx, b, msg, "sticker_delsticker_resolve", err) - } - - // Same read-modify-write on Count as /addsticker, so the same lock. - defer s.lockUser(ownerID)() - - if _, err := b.DeleteStickerFromSet(ctx, &bot.DeleteStickerFromSetParams{Sticker: owned.fileID}); err != nil { - if isStickerSetMissing(err) { - s.dropPackRecord(ctx, ownerID) - } - return replyAPIError(ctx, b, msg, "sticker_delsticker", err) - } - - pack, err := s.adjustCount(ctx, ownerID, -1) - if err != nil { - // The sticker is already gone from the set; only our count is stale. - log.Error("sticker_delsticker_commit", "err", err) - pack = owned.pack - if pack.Count > 0 { - pack.Count-- - } - } - - if pack.Count == 0 { - // Telegram may have removed the now-empty set. /mypack makes no API - // calls so it cannot notice, and /newpack stays blocked while a record - // exists — so name the command that clears it. - return reply(ctx, b, msg, "Removed. Your pack is now empty, and Telegram may have deleted it. "+ - "If /addsticker says the pack is gone, use /delpack to clear it and /newpack to start again.") - } - return reply(ctx, b, msg, fmt.Sprintf("Removed. %s now has %d sticker(s).", pack.Title, pack.Count)) -} - -// handleEditSticker replaces the emoji of a sticker in the caller's pack. -func (s *state) handleEditSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - msg := update.Message - ownerID, err := senderID(msg) - if err != nil { - return reply(ctx, b, msg, senderRefusal) - } - - emoji, err := parseEmoji(commandArgs(msg)) - if err != nil { - return replyErr(ctx, b, msg, "sticker_editsticker_emoji", err) - } - // An empty emoji_list is invalid, so this cannot fall back to a default the - // way /addsticker does: the user has to say what they want. - if len(emoji) == 0 { - return reply(ctx, b, msg, "Usage: /editsticker \nReply to a sticker in your pack with at least one emoji.") - } - - owned, err := s.resolveOwned(ctx, msg, ownerID) - if err != nil { - return replyErr(ctx, b, msg, "sticker_editsticker_resolve", err) - } - - if _, err := b.SetStickerEmojiList(ctx, &bot.SetStickerEmojiListParams{ - Sticker: owned.fileID, - EmojiList: emoji, - }); err != nil { - if isStickerSetMissing(err) { - s.dropPackRecord(ctx, ownerID) - } - return replyAPIError(ctx, b, msg, "sticker_editsticker", err) - } - return reply(ctx, b, msg, "Emoji updated.") -} - -// handleOrderSticker moves a sticker to a new position in the caller's pack. -func (s *state) handleOrderSticker(ctx context.Context, b *bot.Bot, update *models.Update) error { - ctx, cancel := handlerContext(ctx) - defer cancel() - - msg := update.Message - ownerID, err := senderID(msg) - if err != nil { - return reply(ctx, b, msg, senderRefusal) - } - - args := commandArgs(msg) - if len(args) != 1 { - return reply(ctx, b, msg, "Usage: /ordersticker \nReply to a sticker in your pack. Positions start at 0.") - } - pos, err := strconv.Atoi(args[0]) - if err != nil || pos < 0 { - // Only the lower bound is checked locally. The upper bound is the set's - // current size, which Telegram knows and a local copy would not. - return reply(ctx, b, msg, "Position must be a whole number, 0 or greater.") - } - - owned, err := s.resolveOwned(ctx, msg, ownerID) - if err != nil { - return replyErr(ctx, b, msg, "sticker_ordersticker_resolve", err) - } - - if _, err := b.SetStickerPositionInSet(ctx, &bot.SetStickerPositionInSetParams{ - Sticker: owned.fileID, - Position: pos, - }); err != nil { - if isStickerSetMissing(err) { - s.dropPackRecord(ctx, ownerID) - } - return replyAPIError(ctx, b, msg, "sticker_ordersticker", err) - } - return reply(ctx, b, msg, fmt.Sprintf("Moved to position %d.", pos)) -} diff --git a/internal/modules/util/addsticker_command.go b/internal/modules/util/addsticker_command.go new file mode 100644 index 0000000..5696cb2 --- /dev/null +++ b/internal/modules/util/addsticker_command.go @@ -0,0 +1,374 @@ +package util + +import ( + "bytes" + "context" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +// usageAddSticker is the "you must reply to something" line. +const usageAddSticker = "Reply to a sticker, image, video or GIF with this command." + +// imageDocumentMimes is the allowlist for a replied document handled as a still +// image. Anything else is rejected before a single byte is downloaded. +var imageDocumentMimes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, +} + +// movingDocumentMimes is the allowlist for a replied document handled as video. +// +// image/gif lives here rather than with the images: Go's stdlib would decode +// only its first frame, and a GIF is sent to be animated. Transcoding it to +// WEBM keeps the motion. +var movingDocumentMimes = map[string]bool{ + "image/gif": true, + "video/mp4": true, + "video/webm": true, + "video/quicktime": true, + "video/x-matroska": true, +} + +// stickerSource is a resolved sticker ready to be added to a set: whatever the +// replied message carried, reduced to a file_id and the format that file is in. +type stickerSource struct { + fileID string // usable directly as InputSticker.Sticker + format string // one of the stickerFormat* constants + emoji []string // inherited from a replied sticker; at most one element +} + +// addStickerCommand returns /addsticker — public, and deliberately unprefixed +// so the name matches what @Stickers uses. +// +// Every caller writes to the same shared pack, named by STICKER_PACK_NAME. The +// caller's own identity is not used anywhere: AddStickerToSet takes the *set +// owner's* user ID, so there is nothing per-user to store, key, or lock, and +// no ownership to check. That is what lets this live in util as one stateless +// command rather than as a module. +// +// The resolver is built here and captured by the handler so the bot's username +// is fetched at most once per process rather than once per invocation. +func addStickerCommand() modules.Command { + resolver := &botUsernameResolver{} + return modules.Command{ + Name: "addsticker", + Visibility: modules.VisibilityPublic, + Description: "Add the replied sticker, image or video to the shared pack", + Parameters: "[emoji...]", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + return handleAddSticker(ctx, b, update, resolver) + }, + } +} + +func handleAddSticker(ctx context.Context, b *bot.Bot, update *models.Update, resolver *botUsernameResolver) error { + msg := update.Message + if msg == nil { + return nil + } + + // The deadline is chosen from the source type, which the replied message + // states without any API call. A transcode cannot fit the image budget, and + // giving every invocation the video budget would let the still path hold the + // single dispatcher worker far longer than it ever needs. + ctx, cancel := stickerHandlerContext(ctx, hasMovingSource(msg.ReplyToMessage)) + defer cancel() + + pack, err := loadStickerPack() + if err != nil { + // A misconfiguration, not something the caller can fix: log it and + // answer generically rather than explaining the bot's own env. + return replyErr(ctx, b, msg, "util_addsticker_config", err) + } + + // Checked before anything is downloaded or uploaded: a pack this bot cannot + // manage is a dead end, and the create path below would strand the work. + username, err := resolver.resolve(ctx, b) + if err != nil { + return replyErr(ctx, b, msg, "util_addsticker_username", err) + } + title, err := packTitle(pack.Name, username) + if err != nil { + return replyMisconfigured(ctx, b, msg, "util_addsticker_pack_name", err, packNotBotOwnedRefusal) + } + + // Every argument is an emoji: there is no pack token to disambiguate, so a + // stray word fails here rather than being silently read as something else. + emoji, err := parseEmoji(strings.Fields(chathelper.ArgAfterCommand(msg.Text))) + if err != nil { + return replyErr(ctx, b, msg, "util_addsticker_emoji", err) + } + + source, err := resolveStickerSource(ctx, b, pack.OwnerID, msg) + if err != nil { + return replyErr(ctx, b, msg, "util_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} + } + + sticker := models.InputSticker{ + Sticker: source.fileID, + Format: source.format, + EmojiList: emoji, + } + + // Add first, create only when the add proves the set is missing. The + // alternative — probe with getStickerSet, then add — costs an extra call on + // every invocation forever to save one on the single call that creates the + // pack, and getStickerSet returns the set's whole sticker list to do it. + _, err = b.AddStickerToSet(ctx, &bot.AddStickerToSetParams{ + UserID: pack.OwnerID, // the set owner, never the caller + Name: pack.Name, + Sticker: sticker, + }) + switch { + case err == nil: + // No sticker count: nothing is stored, and reading one back would cost + // a getStickerSet call that returns the whole set on every add. + return chathelper.Reply(ctx, b, msg, "Added to the pack.\n"+stickerShareLink(pack.Name)) + case isStickerSetMissing(err): + return createStickerPack(ctx, b, msg, pack, title, sticker) + default: + return replyAPIError(ctx, b, msg, "util_addsticker", err) + } +} + +// createStickerPack creates the shared pack with sticker as its first member. +// +// Reached only from a positive "the set does not exist", and a set cannot be +// created empty — so the sticker that triggered the creation is the one that +// seeds it. The owner is the same account every later add is attributed to, +// which is what keeps the set writable afterwards. +func createStickerPack(ctx context.Context, b *bot.Bot, msg *models.Message, pack stickerPack, title string, sticker models.InputSticker) error { + _, err := b.CreateNewStickerSet(ctx, &bot.CreateNewStickerSetParams{ + UserID: pack.OwnerID, + Name: pack.Name, + Title: title, + Stickers: []models.InputSticker{sticker}, + }) + switch { + case err == nil: + return chathelper.Reply(ctx, b, msg, "Created the shared pack with this sticker.\n"+stickerShareLink(pack.Name)) + case isPackNameOccupied(err): + // The add said the set does not exist and the create says the name is + // taken. Both are true from this bot's side: a set stands there that it + // cannot write to — created for a different owner, or by another bot. + return replyMisconfigured(ctx, b, msg, "util_addsticker_name_taken", err, packNameTakenRefusal) + default: + return replyAPIError(ctx, b, msg, "util_addsticker_create", err) + } +} + +// resolveStickerSource turns the replied message into a sticker source. +// +// ownerID is the *pack owner*, needed only by the photo branch: UploadStickerFile +// associates the uploaded file with a user, and it must be the same account +// that owns the set the file is about to join. +func resolveStickerSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error) { + replied := msg.ReplyToMessage + if replied == nil { + return stickerSource{}, refuse(usageAddSticker) + } + + if st := replied.Sticker; st != nil { + format, err := stickerFormatOf(st) + if err != nil { + return stickerSource{}, err + } + src := stickerSource{fileID: st.FileID, format: format} + 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 + } + + if hasMovingSource(replied) { + return resolveVideoSource(ctx, b, ownerID, replied) + } + + return resolvePhotoSource(ctx, b, ownerID, replied) +} + +// hasMovingSource reports whether the replied message carries footage that must +// be transcoded rather than resampled. +// +// Read from the message alone, with no API call, so the handler can pick its +// deadline before committing to either path. +func hasMovingSource(replied *models.Message) bool { + if replied == nil { + return false + } + if replied.Animation != nil || replied.Video != nil || replied.VideoNote != nil { + return true + } + return replied.Document != nil && movingDocumentMimes[replied.Document.MimeType] +} + +// resolveVideoSource turns a replied video, GIF or animation into an uploaded +// video sticker. +// +// Same shape as resolvePhotoSource — download, convert, upload — but the +// conversion shells out to ffmpeg, and the upload declares the video format so +// Telegram validates it as a WEBM rather than a still. +func resolveVideoSource(ctx context.Context, b *bot.Bot, ownerID int64, replied *models.Message) (stickerSource, error) { + fileID, err := videoFileID(replied) + if err != nil { + return stickerSource{}, err + } + + mediaCtx, cancelMedia := mediaContext(ctx) + defer cancelMedia() + + raw, err := downloadFile(mediaCtx, b, fileID, maxVideoSourceBytes) + if err != nil { + return stickerSource{}, err + } + webm, err := toStickerWEBM(mediaCtx, raw) + if err != nil { + return stickerSource{}, err + } + + uploaded, err := b.UploadStickerFile(mediaCtx, &bot.UploadStickerFileParams{ + UserID: ownerID, + Sticker: &models.InputFileUpload{Filename: "sticker.webm", Data: bytes.NewReader(webm)}, + StickerFormat: stickerFormatVideo, + }) + if err != nil { + return stickerSource{}, err + } + return stickerSource{fileID: uploaded.FileID, format: stickerFormatVideo}, nil +} + +// videoFileID picks the moving file to convert, refusing an oversized one +// before any byte is downloaded. +func videoFileID(replied *models.Message) (string, error) { + var fileID string + var size int64 + switch { + case replied.Animation != nil: + fileID, size = replied.Animation.FileID, replied.Animation.FileSize + case replied.Video != nil: + fileID, size = replied.Video.FileID, replied.Video.FileSize + case replied.VideoNote != nil: + fileID, size = replied.VideoNote.FileID, int64(replied.VideoNote.FileSize) + case replied.Document != nil: + fileID, size = replied.Document.FileID, replied.Document.FileSize + default: + return "", refuse(usageAddSticker) + } + if size > maxVideoSourceBytes { + return "", refuse(tooLargeRefusal(maxVideoSourceBytes)) + } + return fileID, nil +} + +// stickerFormatOf reports which InputSticker.Format a replied sticker's file is +// already in, so it can be copied by file_id without conversion. +// +// A sticker that reached a user came from a set Telegram itself accepted, so it +// already satisfies every dimension, duration and size rule for its format — +// which is exactly why animated and video stickers need no ffmpeg here, while +// an ordinary video or GIF file does. +// +// Type is the half that is easy to miss: a mask sticker and a custom-emoji +// sticker can be any format, and both are invalid in a regular set, so +// switching on the format booleans alone would let them through to fail at the +// API with an opaque error. +func stickerFormatOf(st *models.Sticker) (string, error) { + if st.Type != "" && st.Type != "regular" { + return "", refuse("That is a mask or custom-emoji sticker, which cannot go in a regular pack.") + } + switch { + case st.IsVideo: + return stickerFormatVideo, nil + case st.IsAnimated: + return stickerFormatAnimated, nil + default: + return stickerFormatStatic, nil + } +} + +// 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 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, maxSourceBytes) + 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, format: stickerFormatStatic}, 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(tooLargeRefusal(maxSourceBytes)) + } + 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(tooLargeRefusal(maxSourceBytes)) + } + return doc.FileID, nil + } + + return "", refuse(usageAddSticker) +} diff --git a/internal/modules/util/addsticker_command_test.go b/internal/modules/util/addsticker_command_test.go new file mode 100644 index 0000000..9b4a293 --- /dev/null +++ b/internal/modules/util/addsticker_command_test.go @@ -0,0 +1,376 @@ +package util_test + +import ( + "context" + "strings" + "testing" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +// installAddSticker builds the util module with the shared pack configured and +// getMe stubbed. +// +// getMe must be stubbed explicitly: the bot runs with WithSkipGetMe(), and +// RecordingBot's default reply for an unknown method is a bare `true`, which +// cannot decode into a User. The username matters — it is half of the +// "_by_" suffix that proves the pack is this bot's. +func installAddSticker(t *testing.T, packName, botUsername string) *testutil.RecordingBot { + t.Helper() + t.Setenv("OWNER_ID", "999") + t.Setenv("STICKER_PACK_NAME", packName) + rb := installUtil(t, 999) + rb.StubMethod("getMe", `{"id":1,"is_bot":true,"username":"`+botUsername+`"}`) + return rb +} + +// stickerReply builds "/addsticker " replying to a static sticker. +func stickerReply(userID int64, args, fileID, emoji string) *models.Update { + text := "/addsticker" + if args != "" { + text += " " + args + } + upd := testutil.NewPrivateMessage(userID, text) + upd.Message.ReplyToMessage = &models.Message{ + Sticker: &models.Sticker{FileID: fileID, Type: "regular", Emoji: emoji}, + } + return upd +} + +// callTo returns the first recorded call to the named API method. +func callTo(rb *testutil.RecordingBot, method string) (testutil.SentCall, bool) { + for _, c := range rb.Sent() { + if c.Method == method { + return c, true + } + } + return testutil.SentCall{}, false +} + +// The whole point of the shared-pack model: the sticker is added on behalf of +// the *pack owner*, not the caller, so a non-owner writing to the pack is the +// normal case rather than a refusal. +func TestAddSticker_NonOwnerWritesToConfiguredPack(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(7, "", "src-file-id", "")) + + call, ok := callTo(rb, "addStickerToSet") + if !ok { + t.Fatalf("no addStickerToSet call; got %+v", rb.Sent()) + } + if got := call.Form["user_id"]; got != "999" { + t.Errorf("user_id = %q, want the pack owner %q", got, "999") + } + if got := call.Form["name"]; got != "shared_by_testbot" { + t.Errorf("name = %q, want %q", got, "shared_by_testbot") + } + if got := call.Form["sticker"]; !strings.Contains(got, "src-file-id") { + t.Errorf("sticker = %q, want it to carry the replied file_id", got) + } + rb.AssertSentText(t, "https://t.me/addstickers/shared_by_testbot") + if _, ok := callTo(rb, "createNewStickerSet"); ok { + t.Error("createNewStickerSet called for a pack that already exists") + } +} + +func TestAddSticker_DefaultsToMiti99Pack(t *testing.T) { + rb := installAddSticker(t, "", "miti99bot") + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", "")) + + call, ok := callTo(rb, "addStickerToSet") + if !ok { + t.Fatalf("no addStickerToSet call; got %+v", rb.Sent()) + } + if got := call.Form["name"]; got != "miti99_by_miti99bot" { + t.Errorf("name = %q, want the default pack", got) + } +} + +// Precedence: explicit emoji args beat the replied sticker's own emoji. +func TestAddSticker_EmojiPrecedence(t *testing.T) { + cases := []struct { + name string + args string + replyEmoji string + want string + }{ + {name: "explicit args win", args: "😂🔥", replyEmoji: "🎉", want: "😂"}, + {name: "inherits replied emoji", args: "", replyEmoji: "🎉", want: "🎉"}, + {name: "falls back to default", args: "", replyEmoji: "", want: "⭐"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, tc.args, "src", tc.replyEmoji)) + + call, ok := callTo(rb, "addStickerToSet") + if !ok { + t.Fatalf("no addStickerToSet call; got %+v", rb.Sent()) + } + if got := call.Form["sticker"]; !strings.Contains(got, tc.want) { + t.Errorf("sticker = %q, want emoji_list to carry %q", got, tc.want) + } + }) + } +} + +// A missing set is created rather than reported, seeded with the sticker that +// triggered it, under the same owner every later add is attributed to. +func TestAddSticker_CreatesPackWhenMissing(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERSET_INVALID") + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(7, "🔥", "src-file-id", "")) + + call, ok := callTo(rb, "createNewStickerSet") + if !ok { + t.Fatalf("no createNewStickerSet call; got %+v", rb.Sent()) + } + if got := call.Form["user_id"]; got != "999" { + t.Errorf("user_id = %q, want the pack owner %q", got, "999") + } + if got := call.Form["name"]; got != "shared_by_testbot" { + t.Errorf("name = %q, want %q", got, "shared_by_testbot") + } + // The title is the slug half of the name — everything before "_by_". + if got := call.Form["title"]; got != "shared" { + t.Errorf("title = %q, want %q", got, "shared") + } + if got := call.Form["stickers"]; !strings.Contains(got, "src-file-id") || !strings.Contains(got, "🔥") { + t.Errorf("stickers = %q, want the triggering sticker and its emoji", got) + } + rb.AssertSentText(t, "Created the shared pack") +} + +// A set the add cannot see and the create cannot claim is one this bot cannot +// write to: created for a different owner, or by another bot. +func TestAddSticker_NameTakenBySetItCannotManage(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERSET_INVALID") + rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_OCCUPIED") + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", "")) + + rb.AssertSentText(t, "already exists and this bot cannot manage it") +} + +// The "_by_" suffix is Telegram's proof of authorship, so a +// mismatch is provable offline — before any download, upload, or API call. +func TestAddSticker_RefusesPackNotCreatedByThisBot(t *testing.T) { + for _, packName := range []string{"shared_by_otherbot", "no_suffix_at_all", "_by_testbot"} { + t.Run(packName, func(t *testing.T) { + rb := installAddSticker(t, packName, "testbot") + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", "")) + + rb.AssertSentText(t, "not one this bot can manage") + if _, ok := callTo(rb, "addStickerToSet"); ok { + t.Error("addStickerToSet called for a pack this bot cannot manage") + } + if _, ok := callTo(rb, "createNewStickerSet"); ok { + t.Error("createNewStickerSet called for a name this bot cannot use") + } + }) + } +} + +func TestAddSticker_NoReply_ShowsUsage(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/addsticker")) + + rb.AssertSentText(t, "Reply to a sticker, image, video or GIF") + if _, ok := callTo(rb, "addStickerToSet"); ok { + t.Error("addStickerToSet called with nothing to add") + } +} + +// A misconfigured owner is not the caller's fault and must not leak the bot's +// own env into a reply. +func TestAddSticker_OwnerIDUnset_RepliesGenerically(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + t.Setenv("OWNER_ID", "") // overrides the helper's value for this test + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", "")) + + got := rb.LastSent().Text() + if !strings.Contains(got, "Something went wrong") { + t.Errorf("reply = %q, want the generic failure", got) + } + if strings.Contains(got, "OWNER_ID") { + t.Errorf("reply leaked the env var name: %q", got) + } + if _, ok := callTo(rb, "addStickerToSet"); ok { + t.Error("addStickerToSet called without a pack owner") + } +} + +// A sticker already in a set satisfies its format's rules, so it is copied by +// file_id with no conversion — the format just has to be reported correctly. +func TestAddSticker_CopiesEachStickerFormat(t *testing.T) { + cases := []struct { + name string + sticker *models.Sticker + want string + }{ + {name: "static", sticker: &models.Sticker{FileID: "src", Type: "regular"}, want: "static"}, + {name: "animated", sticker: &models.Sticker{FileID: "src", IsAnimated: true}, want: "animated"}, + {name: "video", sticker: &models.Sticker{FileID: "src", IsVideo: true}, want: "video"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + upd := testutil.NewPrivateMessage(999, "/addsticker") + upd.Message.ReplyToMessage = &models.Message{Sticker: tc.sticker} + + rb.Bot.ProcessUpdate(context.Background(), upd) + + call, ok := callTo(rb, "addStickerToSet") + if !ok { + t.Fatalf("no addStickerToSet call; got %+v", rb.Sent()) + } + if got := call.Form["sticker"]; !strings.Contains(got, `"format":"`+tc.want+`"`) { + t.Errorf("sticker = %q, want format %q", got, tc.want) + } + // No upload: a file_id copy never touches uploadStickerFile. + if _, ok := callTo(rb, "uploadStickerFile"); ok { + t.Error("uploadStickerFile called for a file_id copy") + } + }) + } +} + +// Format is per-sticker but sticker_type is fixed at creation, and the shared +// pack is regular — so these are refused whatever format they are in. +func TestAddSticker_RejectsMaskAndCustomEmojiStickers(t *testing.T) { + for _, stickerType := range []string{"mask", "custom_emoji"} { + t.Run(stickerType, func(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + upd := testutil.NewPrivateMessage(999, "/addsticker") + upd.Message.ReplyToMessage = &models.Message{ + Sticker: &models.Sticker{FileID: "src", Type: stickerType, IsVideo: true}, + } + + rb.Bot.ProcessUpdate(context.Background(), upd) + + rb.AssertSentText(t, "mask or custom-emoji") + if _, ok := callTo(rb, "addStickerToSet"); ok { + t.Error("addStickerToSet called with a mask or custom-emoji source") + } + }) + } +} + +// Moving sources take the transcode path, which begins with getFile. Asserting +// the routing rather than the finished sticker keeps the test independent of +// whether the host has ffmpeg — the conversion itself is covered by +// TestToStickerWEBM_* against real ffmpeg. +func TestAddSticker_RoutesMovingSourcesToTheVideoPath(t *testing.T) { + cases := []struct { + name string + replied *models.Message + }{ + {name: "animation (telegram GIF)", replied: &models.Message{Animation: &models.Animation{FileID: "src", MimeType: "video/mp4"}}}, + {name: "video", replied: &models.Message{Video: &models.Video{FileID: "src", MimeType: "video/mp4"}}}, + {name: "video note", replied: &models.Message{VideoNote: &models.VideoNote{FileID: "src"}}}, + {name: "gif document", replied: &models.Message{Document: &models.Document{FileID: "src", MimeType: "image/gif"}}}, + {name: "mp4 document", replied: &models.Message{Document: &models.Document{FileID: "src", MimeType: "video/mp4"}}}, + {name: "webm document", replied: &models.Message{Document: &models.Document{FileID: "src", MimeType: "video/webm"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + upd := testutil.NewPrivateMessage(999, "/addsticker") + upd.Message.ReplyToMessage = tc.replied + + rb.Bot.ProcessUpdate(context.Background(), upd) + + if _, ok := callTo(rb, "getFile"); !ok { + t.Errorf("getFile not called; the source did not reach the video path: %+v", rb.Sent()) + } + // The stubbed download cannot yield a real clip, so the add must + // never happen — what matters is that it failed at the conversion + // rather than being refused as an unsupported reply. + if _, ok := callTo(rb, "addStickerToSet"); ok { + t.Error("addStickerToSet called with an unconvertible source") + } + }) + } +} + +// An oversized clip is refused from the message metadata alone, so nothing is +// downloaded and ffmpeg is never reached. +func TestAddSticker_RefusesOversizedVideo(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + upd := testutil.NewPrivateMessage(999, "/addsticker") + upd.Message.ReplyToMessage = &models.Message{ + Video: &models.Video{FileID: "src", MimeType: "video/mp4", FileSize: 11 << 20}, + } + + rb.Bot.ProcessUpdate(context.Background(), upd) + + rb.AssertSentText(t, "too large") + for _, method := range []string{"getFile", "uploadStickerFile", "addStickerToSet"} { + if _, ok := callTo(rb, method); ok { + t.Errorf("%s called for an oversized source", method) + } + } +} + +// A document that is neither a supported image nor convertible video still +// gets the image advice, not a video error. +func TestAddSticker_RefusesUnsupportedDocument(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + upd := testutil.NewPrivateMessage(999, "/addsticker") + upd.Message.ReplyToMessage = &models.Message{ + Document: &models.Document{FileID: "src", MimeType: "application/pdf"}, + } + + rb.Bot.ProcessUpdate(context.Background(), upd) + + rb.AssertSentText(t, "not a supported image") + if _, ok := callTo(rb, "getFile"); ok { + t.Error("getFile called for an unsupported document") + } +} + +func TestAddSticker_NonEmojiArgRefused(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "hello", "src", "")) + + rb.AssertSentText(t, "is not an emoji") + if _, ok := callTo(rb, "addStickerToSet"); ok { + t.Error("addStickerToSet called with a non-emoji argument") + } +} + +// Telegram's MTProto codes arrive as "Bad Request: " and must be +// translated, never echoed. +func TestAddSticker_APIRefusalsTranslated(t *testing.T) { + cases := []struct { + name string + description string + want string + }{ + {name: "pack full", description: "Bad Request: STICKERS_TOO_MUCH", want: "full (120 stickers)"}, + {name: "bad emoji", description: "Bad Request: invalid sticker emojis", want: "rejected those emoji"}, + {name: "bad name", description: "Bad Request: PACK_SHORT_NAME_INVALID", want: "rejected the shared pack's name"}, + {name: "unmapped", description: "Bad Request: SOMETHING_NEW", want: "Something went wrong"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rb := installAddSticker(t, "shared_by_testbot", "testbot") + rb.FailMethodCode("addStickerToSet", 400, tc.description) + + rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", "")) + + rb.AssertSentText(t, tc.want) + }) + } +} diff --git a/internal/modules/sticker/download.go b/internal/modules/util/sticker_download.go similarity index 73% rename from internal/modules/sticker/download.go rename to internal/modules/util/sticker_download.go index ae8ea68..a835c30 100644 --- a/internal/modules/sticker/download.go +++ b/internal/modules/util/sticker_download.go @@ -1,4 +1,4 @@ -package sticker +package util import ( "context" @@ -15,11 +15,17 @@ import ( ) const ( - // maxSourceBytes bounds what this module will pull over the network. + // maxSourceBytes bounds what an image source may 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 + // maxVideoSourceBytes is the same bound for a moving source. Higher than + // maxSourceBytes because the ceiling is on the *source*, and a few seconds + // of H.264 is far larger than any still Telegram would deliver — the + // 256 KB sticker limit is enforced on the transcode output instead. + maxVideoSourceBytes = 10 << 20 + + // downloadTimeout is this command'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 @@ -33,7 +39,7 @@ const ( // 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") +var errDownloadFailed = errors.New("util: sticker download failed") // downloadClient is separate from the library's so its timeout is ours. var downloadClient = &http.Client{Timeout: downloadTimeout} @@ -42,14 +48,14 @@ var downloadClient = &http.Client{Timeout: downloadTimeout} // // 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) { +func downloadFile(ctx context.Context, b *bot.Bot, fileID string, maxBytes int64) ([]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)) + if f.FileSize > maxBytes { + return nil, refuse(tooLargeRefusal(maxBytes)) } link := b.FileDownloadLink(f) @@ -72,17 +78,24 @@ func downloadFile(ctx context.Context, b *bot.Bot, fileID string) ([]byte, error // 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)) + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+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)) + if int64(len(data)) > maxBytes { + return nil, refuse(tooLargeRefusal(maxBytes)) } return data, nil } +// tooLargeRefusal states the cap that was exceeded. The limit is a parameter, +// so it must come from the same value the check used rather than a constant a +// later edit could leave behind. +func tooLargeRefusal(maxBytes int64) string { + return fmt.Sprintf("That file is too large — keep it under %d MB.", maxBytes>>20) +} + // 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 diff --git a/internal/modules/sticker/download_test.go b/internal/modules/util/sticker_download_test.go similarity index 95% rename from internal/modules/sticker/download_test.go rename to internal/modules/util/sticker_download_test.go index 9bb7db2..2dcb9b1 100644 --- a/internal/modules/sticker/download_test.go +++ b/internal/modules/util/sticker_download_test.go @@ -1,4 +1,4 @@ -package sticker +package util import ( "context" @@ -49,7 +49,7 @@ func TestDownloadFile_ErrorNeverLeaksTokenOrURL(t *testing.T) { t.Fatalf("bot.New: %v", err) } - _, err = downloadFile(context.Background(), b, "f1") + _, err = downloadFile(context.Background(), b, "f1", maxSourceBytes) if err == nil { t.Fatal("downloadFile succeeded against a hung-up server; want an error") } @@ -111,7 +111,7 @@ func TestDownloadFile_RejectsOversizedBeforeFetching(t *testing.T) { t.Fatalf("bot.New: %v", err) } - if _, err := downloadFile(context.Background(), b, "f1"); err == nil { + if _, err := downloadFile(context.Background(), b, "f1", maxSourceBytes); err == nil { t.Fatal("downloadFile accepted an oversized file") } if fetches != 0 { @@ -150,7 +150,7 @@ func TestDownloadFile_BoundsBodyRegardlessOfContentLength(t *testing.T) { t.Fatalf("bot.New: %v", err) } - data, err := downloadFile(context.Background(), b, "f1") + data, err := downloadFile(context.Background(), b, "f1", maxSourceBytes) 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/util/sticker_emoji.go similarity index 97% rename from internal/modules/sticker/emoji.go rename to internal/modules/util/sticker_emoji.go index bdf00e1..8ee7ded 100644 --- a/internal/modules/sticker/emoji.go +++ b/internal/modules/util/sticker_emoji.go @@ -1,4 +1,4 @@ -package sticker +package util import ( "fmt" @@ -34,9 +34,9 @@ const ( // 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. +// /addsticker takes no argument but emoji, so every argument reaching here is +// meant to be one: a stray word fails loudly rather than being silently +// reinterpreted. func parseEmoji(args []string) ([]string, error) { joined := strings.Join(args, "") joined = strings.TrimSpace(joined) diff --git a/internal/modules/sticker/emoji_test.go b/internal/modules/util/sticker_emoji_test.go similarity index 99% rename from internal/modules/sticker/emoji_test.go rename to internal/modules/util/sticker_emoji_test.go index 8d1b1b7..a9ec5aa 100644 --- a/internal/modules/sticker/emoji_test.go +++ b/internal/modules/util/sticker_emoji_test.go @@ -1,4 +1,4 @@ -package sticker +package util import ( "strings" diff --git a/internal/modules/sticker/image.go b/internal/modules/util/sticker_image.go similarity index 86% rename from internal/modules/sticker/image.go rename to internal/modules/util/sticker_image.go index 28584eb..ad5d471 100644 --- a/internal/modules/sticker/image.go +++ b/internal/modules/util/sticker_image.go @@ -1,4 +1,4 @@ -package sticker +package util import ( "bytes" @@ -21,9 +21,6 @@ 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. // @@ -88,25 +85,6 @@ func toStickerPNG(src []byte) ([]byte, error) { 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) { diff --git a/internal/modules/sticker/image_test.go b/internal/modules/util/sticker_image_test.go similarity index 91% rename from internal/modules/sticker/image_test.go rename to internal/modules/util/sticker_image_test.go index 9fe578a..5ec4e31 100644 --- a/internal/modules/sticker/image_test.go +++ b/internal/modules/util/sticker_image_test.go @@ -1,4 +1,4 @@ -package sticker +package util import ( "bytes" @@ -109,18 +109,6 @@ func TestToStickerPNG_PreservesAlpha(t *testing.T) { } } -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) { diff --git a/internal/modules/util/sticker_pack.go b/internal/modules/util/sticker_pack.go new file mode 100644 index 0000000..cf8ba22 --- /dev/null +++ b/internal/modules/util/sticker_pack.go @@ -0,0 +1,317 @@ +package util + +import ( + "context" + "errors" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +const ( + // stickerPackNameEnv overrides which set /addsticker writes to. The set + // must already exist and must have been created by this bot, which is the + // only thing that makes it bot-manageable — there is no command to create + // one, by design. + stickerPackNameEnv = "STICKER_PACK_NAME" + + // defaultStickerPackName is the shared pack used when the env is unset. + defaultStickerPackName = "miti99_by_miti99bot" + + // stickerPackOwnerEnv reuses the bot-wide owner setting rather than + // introducing a second variable: AddStickerToSet needs the *set owner's* + // user ID, and the default pack above belongs to the bot owner. A pack + // owned by any other account needs this env pointed at that account. + stickerPackOwnerEnv = "OWNER_ID" + + // InputSticker.Format values. Since Bot API 7.2 the format is a property of + // each sticker rather than of the set — createNewStickerSet lost its + // sticker_format parameter and StickerSet lost is_animated/is_video — so + // one pack holds all three side by side and no migration is needed to + // start adding a new one. + stickerFormatStatic = "static" // .WEBP or .PNG + stickerFormatAnimated = "animated" // .TGS + stickerFormatVideo = "video" // .WEBM + + // maxStickersPerPack is Telegram's documented ceiling for a regular set. + // Not enforced locally — the server is the authority and a local copy would + // go stale — but quoted back to the user when the server refuses. + maxStickersPerPack = 120 + + // maxSetNameLen is Telegram's cap on a sticker set's short name. + maxSetNameLen = 64 + + // setNameSuffix prefixes the mandatory "_by_" tail. + setNameSuffix = "_by_" + + // stickerHandlerTimeout bounds the /addsticker handler for a still source. + // + // 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. The photo path makes three sequential API calls and + // could otherwise freeze the bot for every user for minutes. + stickerHandlerTimeout = 10 * time.Second + + // stickerVideoHandlerTimeout is the same bound for a source that has to be + // transcoded: the download can be ten times larger and an ffmpeg encode + // sits between it and the upload. + // + // The still budget is kept separate rather than raising both, because this + // number is time the whole bot is unresponsive and only one of the two + // paths needs it. + stickerVideoHandlerTimeout = 45 * time.Second +) + +// stickerPack is the resolved target of /addsticker. +type stickerPack struct { + Name string // Telegram set name, e.g. "miti99_by_miti99bot" + OwnerID int64 // the account the set belongs to; AddStickerToSet demands it +} + +// errNoPackOwner means the owner ID is unset, so no sticker can be added. +// Internal, not user-facing: nothing the caller does fixes a misconfiguration. +var errNoPackOwner = errors.New("util: sticker pack owner ID unset") + +// loadStickerPack resolves the shared pack from the environment. +// +// Read per invocation rather than captured at startup, matching how gold and +// lol read their credentials: it keeps the command testable with t.Setenv and +// costs nothing next to the API calls that follow. +func loadStickerPack() (stickerPack, error) { + name := strings.TrimSpace(os.Getenv(stickerPackNameEnv)) + if name == "" { + name = defaultStickerPackName + } + ownerID, err := strconv.ParseInt(strings.TrimSpace(os.Getenv(stickerPackOwnerEnv)), 10, 64) + if err != nil || ownerID == 0 { + return stickerPack{}, errNoPackOwner + } + return stickerPack{Name: name, OwnerID: ownerID}, nil +} + +// stickerShareLink is the public URL of a sticker set. +func stickerShareLink(setName string) string { + return "https://t.me/addstickers/" + setName +} + +// errNoUsername means the bot's own username is unavailable, so the configured +// pack cannot be checked against it. Internal and transient, not user-facing. +var errNoUsername = errors.New("util: bot has no username") + +// errPackNotBotOwned means the configured name cannot belong to a set this bot +// created. Internal: the reply is a fixed operator-facing sentence. +var errPackNotBotOwned = errors.New("util: configured pack is not manageable by this bot") + +// packNotBotOwnedRefusal answers a configured name this bot provably cannot +// manage. It names no environment variable — the detail goes to the log. +const packNotBotOwnedRefusal = "The shared pack is not one this bot can manage. Ask the bot owner to check its configuration." + +// packNameTakenRefusal answers a name that is occupied by a set this bot cannot +// write to: created by another bot, or by this bot for a different owner. +const packNameTakenRefusal = "A sticker set with that name already exists and this bot cannot manage it. Ask the bot owner to check it." + +// packTitle validates that name could only be a set this bot created, and +// returns the slug half of it for use as the title at creation time. +// +// The "_by_" suffix is Telegram's own proof of authorship: +// createNewStickerSet *requires* the calling bot's username there, so a name +// without it cannot have come from this bot, and a name with it cannot have +// come from another. That makes the check purely local — no API call, and no +// need for a field the API does not expose. +// +// It cannot prove the *owner*, only the creator. StickerSet carries no owner +// ID, so "created by this bot but for a different user" is indistinguishable +// here and only surfaces when Telegram refuses the write. +func packTitle(name, botUsername string) (string, error) { + if botUsername == "" { + return "", errNoUsername + } + if len(name) > maxSetNameLen { + return "", errPackNotBotOwned + } + suffix := setNameSuffix + botUsername + // Strictly longer, not equal: the slug half must carry at least one + // character, and Telegram requires a name to begin with a letter. + if len(name) <= len(suffix) || !strings.EqualFold(name[len(name)-len(suffix):], suffix) { + return "", errPackNotBotOwned + } + return name[:len(name)-len(suffix)], nil +} + +// botUsernameResolver caches the bot's username. +// +// 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 /addsticker for the process's lifetime. +type botUsernameResolver 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 *botUsernameResolver) 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 +} + +// isStickerSetMissing reports whether err positively says the set does not +// exist on Telegram's side. +// +// Classification is positive-only, and deliberately so: this is the signal +// that authorises *creating* a set. A network blip, a 429, or a context +// cancelled by SIGTERM must never be read as "the pack is gone", or a routine +// failure would turn into an attempt to create a set that already exists. +func isStickerSetMissing(err error) bool { + return errors.Is(err, bot.ErrorBadRequest) && + strings.Contains(err.Error(), "STICKERSET_INVALID") +} + +// isPackNameOccupied reports whether err says the short name is already taken. +// +// Reaching this after isStickerSetMissing is the one proof available that a set +// exists under the configured name which this bot cannot write to. Kept as its +// own predicate rather than folded into apiRefusal, because that function's job +// is wording and this one's is a control-flow decision. +func isPackNameOccupied(err error) bool { + return err != nil && contains(err.Error(), "PACK_SHORT_NAME_OCCUPIED", "already occupied") +} + +// userError carries text meant to be shown to the user verbatim. +// +// There are two kinds of failure here and they must never be confused: a +// refusal the user can act on ("that image is too large"), and an internal +// failure that must not reach a reply at all — 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 per 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} } + +const genericFailure = "Something went wrong. Try again in a moment." + +// stickerHandlerContext applies the /addsticker deadline for the path the +// replied source will take. +func stickerHandlerContext(ctx context.Context, transcoding bool) (context.Context, context.CancelFunc) { + if transcoding { + return context.WithTimeout(ctx, stickerVideoHandlerTimeout) + } + return context.WithTimeout(ctx, stickerHandlerTimeout) +} + +// mediaContext bounds the download-and-upload leg, reserving the tail of the +// parent's budget for the reply that follows. +// +// The photo path spends most of its deadline before it has anything to say. +// 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. +func mediaContext(ctx context.Context) (context.Context, context.CancelFunc) { + return chathelper.FetchContext(ctx) +} + +// 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 that must never be echoed. +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 chathelper.Reply(ctx, b, msg, ue.msg) + } + log.Error(op, "err", err) + return chathelper.Reply(ctx, b, msg, genericFailure) +} + +// replyMisconfigured answers a bad pack configuration. +// +// Distinct from replyErr because these failures need both halves: the operator +// needs the precise cause in the log, and the user needs a sentence that tells +// them to go tell the owner. A userError would give the second without the +// first; a plain error would give the first and a useless generic reply. +func replyMisconfigured(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error, text string) error { + log.Error(op, "err", err) + return chathelper.Reply(ctx, b, msg, text) +} + +// 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 some of these into English (STICKER_EMOJI_INVALID); the rest +// arrive as "Bad Request: ", and the prose 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_INVALID", "invalid sticker set name"): + return "Telegram rejected the shared pack's name. Ask the bot owner to check its configuration.", true + case contains(text, "PACK_TITLE_INVALID"): + return "Telegram rejected the shared pack's title. Ask the bot owner to check its configuration.", true + case contains(text, "STICKERS_TOO_MUCH"): + return "The shared pack is full (" + strconv.Itoa(maxStickersPerPack) + " 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 chathelper.Reply(ctx, b, msg, text) + } + log.Error(op, "err", err) + return chathelper.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 +} diff --git a/internal/modules/util/sticker_pack_test.go b/internal/modules/util/sticker_pack_test.go new file mode 100644 index 0000000..68284a3 --- /dev/null +++ b/internal/modules/util/sticker_pack_test.go @@ -0,0 +1,151 @@ +package util + +import ( + "errors" + "strings" + "testing" +) + +// packTitle is the only ownership check available offline, so its edges matter: +// a false accept sends the bot at a set it cannot manage, and a false reject +// disables /addsticker outright. +func TestPackTitle(t *testing.T) { + cases := []struct { + name string + packName string + username string + wantTitle string + wantErr error + }{ + { + name: "slug half becomes the title", + packName: "miti99_by_miti99bot", + username: "miti99bot", + wantTitle: "miti99", + }, + { + // Telegram documents as case insensitive, and + // returns SetName with whatever casing the set was created with. + name: "suffix matches case insensitively", + packName: "Shared_By_TestBot", + username: "testbot", + wantTitle: "Shared", + }, + { + name: "underscores inside the slug survive", + packName: "my_shared_pack_by_testbot", + username: "testbot", + wantTitle: "my_shared_pack", + }, + { + name: "another bot's set", + packName: "shared_by_otherbot", + username: "testbot", + wantErr: errPackNotBotOwned, + }, + { + name: "no suffix at all", + packName: "shared", + username: "testbot", + wantErr: errPackNotBotOwned, + }, + { + // The suffix alone leaves no slug, and Telegram requires a name to + // begin with a letter. + name: "empty slug", + packName: "_by_testbot", + username: "testbot", + wantErr: errPackNotBotOwned, + }, + { + name: "past Telegram's name length cap", + packName: strings.Repeat("a", maxSetNameLen-len("_by_testbot")+1) + "_by_testbot", + username: "testbot", + wantErr: errPackNotBotOwned, + }, + { + // A GetMe that returned no username must not be read as "matches + // nothing", which would report a configuration fault instead of a + // transient one. + name: "unknown bot username", + packName: "shared_by_testbot", + username: "", + wantErr: errNoUsername, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := packTitle(tc.packName, tc.username) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("packTitle(%q, %q) err = %v, want %v", tc.packName, tc.username, err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("packTitle(%q, %q): %v", tc.packName, tc.username, err) + } + if got != tc.wantTitle { + t.Errorf("title = %q, want %q", got, tc.wantTitle) + } + }) + } +} + +// The name is at Telegram's exact cap, which must be accepted rather than +// rejected off-by-one. +func TestPackTitle_AtNameLengthCap(t *testing.T) { + slug := strings.Repeat("a", maxSetNameLen-len("_by_testbot")) + got, err := packTitle(slug+"_by_testbot", "testbot") + if err != nil { + t.Fatalf("packTitle at the %d-char cap: %v", maxSetNameLen, err) + } + if got != slug { + t.Errorf("title = %q, want %q", got, slug) + } +} + +func TestLoadStickerPack(t *testing.T) { + t.Run("defaults the name and requires an owner", func(t *testing.T) { + t.Setenv("OWNER_ID", "42") + t.Setenv("STICKER_PACK_NAME", "") + pack, err := loadStickerPack() + if err != nil { + t.Fatalf("loadStickerPack: %v", err) + } + if pack.Name != defaultStickerPackName { + t.Errorf("name = %q, want %q", pack.Name, defaultStickerPackName) + } + if pack.OwnerID != 42 { + t.Errorf("ownerID = %d, want 42", pack.OwnerID) + } + }) + + // A zero owner is the unset case, not a valid user: AddStickerToSet needs a + // real account, so it must fail here rather than at the API. + for _, owner := range []string{"", "0", "not-a-number"} { + t.Run("rejects owner "+owner, func(t *testing.T) { + t.Setenv("OWNER_ID", owner) + if _, err := loadStickerPack(); !errors.Is(err, errNoPackOwner) { + t.Errorf("loadStickerPack() err = %v, want errNoPackOwner", err) + } + }) + } +} + +func TestIsStickerSetMissingAndOccupied(t *testing.T) { + // isStickerSetMissing is positive-only: it authorises creating a set, so a + // transport failure must never satisfy it. + if isStickerSetMissing(errors.New("connection reset")) { + t.Error("a plain error was read as a missing sticker set") + } + if isStickerSetMissing(errors.New("Bad Request: STICKERSET_INVALID")) { + t.Error("a codeless error was read as a missing sticker set") + } + if !isPackNameOccupied(errors.New("Bad Request: PACK_SHORT_NAME_OCCUPIED")) { + t.Error("PACK_SHORT_NAME_OCCUPIED not recognised") + } + if isPackNameOccupied(nil) { + t.Error("nil error read as occupied") + } +} diff --git a/internal/modules/sticker/photo_test.go b/internal/modules/util/sticker_photo_test.go similarity index 99% rename from internal/modules/sticker/photo_test.go rename to internal/modules/util/sticker_photo_test.go index 992a62e..3f8b9fa 100644 --- a/internal/modules/sticker/photo_test.go +++ b/internal/modules/util/sticker_photo_test.go @@ -1,4 +1,4 @@ -package sticker +package util import ( "testing" diff --git a/internal/modules/util/sticker_source_test.go b/internal/modules/util/sticker_source_test.go new file mode 100644 index 0000000..c0b27ee --- /dev/null +++ b/internal/modules/util/sticker_source_test.go @@ -0,0 +1,127 @@ +package util + +import ( + "testing" + + "github.com/go-telegram/bot/models" +) + +// hasMovingSource picks which of the two conversion paths a reply takes, from +// the message alone. Getting it wrong sends a video at the image resampler, or +// an image at ffmpeg. +func TestHasMovingSource(t *testing.T) { + cases := []struct { + name string + replied *models.Message + want bool + }{ + {name: "nil reply", replied: nil, want: false}, + {name: "animation", replied: &models.Message{Animation: &models.Animation{FileID: "a"}}, want: true}, + {name: "video", replied: &models.Message{Video: &models.Video{FileID: "v"}}, want: true}, + {name: "video note", replied: &models.Message{VideoNote: &models.VideoNote{FileID: "n"}}, want: true}, + {name: "gif document", replied: &models.Message{Document: &models.Document{MimeType: "image/gif"}}, want: true}, + {name: "mp4 document", replied: &models.Message{Document: &models.Document{MimeType: "video/mp4"}}, want: true}, + {name: "webm document", replied: &models.Message{Document: &models.Document{MimeType: "video/webm"}}, want: true}, + {name: "mov document", replied: &models.Message{Document: &models.Document{MimeType: "video/quicktime"}}, want: true}, + {name: "png document", replied: &models.Message{Document: &models.Document{MimeType: "image/png"}}, want: false}, + {name: "photo", replied: &models.Message{Photo: []models.PhotoSize{{FileID: "p"}}}, want: false}, + {name: "sticker", replied: &models.Message{Sticker: &models.Sticker{FileID: "s"}}, want: false}, + {name: "pdf document", replied: &models.Message{Document: &models.Document{MimeType: "application/pdf"}}, want: false}, + {name: "plain text", replied: &models.Message{Text: "hello"}, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := hasMovingSource(tc.replied); got != tc.want { + t.Errorf("hasMovingSource = %v, want %v", got, tc.want) + } + }) + } +} + +// Source selection happens before any download, so an oversized clip costs +// nothing. +func TestVideoFileID(t *testing.T) { + cases := []struct { + name string + replied *models.Message + want string + ok bool + }{ + { + name: "animation", + replied: &models.Message{Animation: &models.Animation{FileID: "anim", FileSize: 1 << 20}}, + want: "anim", ok: true, + }, + { + name: "video", + replied: &models.Message{Video: &models.Video{FileID: "vid", FileSize: 1 << 20}}, + want: "vid", ok: true, + }, + { + name: "video note", + replied: &models.Message{VideoNote: &models.VideoNote{FileID: "note", FileSize: 1 << 20}}, + want: "note", ok: true, + }, + { + name: "document", + replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "video/mp4"}}, + want: "doc", ok: true, + }, + { + // Animation is checked before Video: Telegram attaches both to a + // GIF-style message, and the animation is the one to convert. + name: "animation wins over video", + replied: &models.Message{ + Animation: &models.Animation{FileID: "anim"}, + Video: &models.Video{FileID: "vid"}, + }, + want: "anim", ok: true, + }, + { + name: "oversized animation refused", + replied: &models.Message{Animation: &models.Animation{FileID: "anim", FileSize: maxVideoSourceBytes + 1}}, + ok: false, + }, + { + name: "oversized video refused", + replied: &models.Message{Video: &models.Video{FileID: "vid", FileSize: maxVideoSourceBytes + 1}}, + ok: false, + }, + { + name: "oversized document refused", + replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "video/mp4", FileSize: maxVideoSourceBytes + 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 := videoFileID(tc.replied) + if tc.ok { + if err != nil { + t.Fatalf("videoFileID: %v", err) + } + if got != tc.want { + t.Errorf("file_id = %q, want %q", got, tc.want) + } + return + } + if err == nil { + t.Fatalf("videoFileID = %q, want a refusal", got) + } + }) + } +} + +// The video cap is the one on the source, and it is an order of magnitude above +// the image cap on purpose — the 256 KB sticker limit applies to the transcode +// output, not the input. +func TestVideoSourceCapExceedsImageCap(t *testing.T) { + if maxVideoSourceBytes <= maxSourceBytes { + t.Errorf("maxVideoSourceBytes (%d) must exceed maxSourceBytes (%d)", maxVideoSourceBytes, maxSourceBytes) + } +} diff --git a/internal/modules/util/sticker_video.go b/internal/modules/util/sticker_video.go new file mode 100644 index 0000000..98d615b --- /dev/null +++ b/internal/modules/util/sticker_video.go @@ -0,0 +1,151 @@ +package util + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "time" + + "github.com/tiennm99/miti99bot/internal/log" +) + +const ( + // ffmpegBinary is looked up on PATH. The runtime image installs it; see the + // Dockerfile. Nothing else in this bot shells out, so this is the one place + // an external binary is a hard dependency. + ffmpegBinary = "ffmpeg" + + // Telegram's video-sticker rules (core.telegram.org/stickers): one side + // exactly 512px and the other 512 or less, at most 3 seconds, at most + // 30 FPS, at most 256 KB, WEBM/VP9, and **no audio stream**. + maxStickerVideoSeconds = 3 + maxStickerVideoFPS = 30 + maxStickerVideoBytes = 256 << 10 + + // ffmpegTimeout bounds one encode. + // + // Handlers run inline on a single worker, so this is time the whole bot is + // unresponsive. It is set well above the observed cost (a 1280x720 source + // encodes in ~0.4s with the flags below) purely so a pathological input + // fails rather than hangs — not as a budget to be spent. + ffmpegTimeout = 20 * time.Second +) + +// errFFmpegMissing means the runtime image has no ffmpeg. A deployment fault, +// not a user error: it must never be echoed as a refusal the caller could act +// on, because nothing they send will help. +var errFFmpegMissing = errors.New("util: ffmpeg not found on PATH") + +// stickerVideoCRFs is the quality ladder, tried in order until the output fits +// maxStickerVideoBytes. +// +// Lower is better quality and larger. 32 lands around 30 KB for ordinary +// footage — an order of magnitude inside the limit — so the later rungs exist +// for dense, high-motion sources rather than as the expected path. +var stickerVideoCRFs = []int{32, 42, 52} + +// toStickerWEBM converts arbitrary video or GIF bytes into a WEBM/VP9 sticker. +// +// Every rule Telegram enforces is applied by the filter chain and the encoder +// flags rather than checked afterwards: the long edge is scaled to exactly 512 +// (up or down — a 100x50 GIF becomes 512x256, not left undersized), the stream +// is cut at 3 seconds, the frame rate is capped at 30, and audio, subtitle and +// data streams are dropped outright. Only the size limit needs a retry, since +// it cannot be known before encoding. +func toStickerWEBM(ctx context.Context, src []byte) ([]byte, error) { + if _, err := exec.LookPath(ffmpegBinary); err != nil { + return nil, errFFmpegMissing + } + + dir, err := os.MkdirTemp("", "sticker-video-*") + if err != nil { + return nil, fmt.Errorf("sticker video: temp dir: %w", err) + } + defer func() { _ = os.RemoveAll(dir) }() + + // No extension: ffmpeg probes the container from the content, so trusting a + // caller-supplied name buys nothing and risks steering the demuxer wrong. + in := filepath.Join(dir, "in") + if err := os.WriteFile(in, src, 0o600); err != nil { + return nil, fmt.Errorf("sticker video: write source: %w", err) + } + out := filepath.Join(dir, "out.webm") + + var best []byte + for _, crf := range stickerVideoCRFs { + data, err := runFFmpeg(ctx, in, out, crf) + if err != nil { + return nil, err + } + best = data + if len(best) <= maxStickerVideoBytes { + return best, nil + } + } + + // Past the last rung. Hand back the smallest attempt and let Telegram be + // the authority: the 256 KB figure is documented, but the server is what + // actually accepts or refuses, and apiRefusal already translates its answer. + log.Error("sticker_video_oversized", "bytes", len(best)) + return best, nil +} + +// runFFmpeg performs one encode at the given quality and returns the result. +func runFFmpeg(ctx context.Context, in, out string, crf int) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, ffmpegTimeout) + defer cancel() + + // scale sets the *longer* edge to exactly 512 and lets the other fall out + // of the aspect ratio, rounded to an even number for yuv chroma. This is + // deliberately not force_original_aspect_ratio=decrease, which leaves a + // source smaller than 512 undersized and so fails Telegram's "one side must + // be exactly 512" rule. + filter := fmt.Sprintf( + "scale=w='if(gte(iw,ih),%d,-2)':h='if(gte(iw,ih),-2,%d)':flags=lanczos,fps=%d", + stickerEdge, stickerEdge, maxStickerVideoFPS) + + cmd := exec.CommandContext(ctx, ffmpegBinary, + "-hide_banner", "-loglevel", "error", + "-nostdin", // never wait on a terminal; there is none + "-y", + "-i", in, + "-t", strconv.Itoa(maxStickerVideoSeconds), + "-an", "-sn", "-dn", // no audio, subtitle or data streams + "-vf", filter, + "-c:v", "libvpx-vp9", + "-pix_fmt", "yuva420p", // keeps GIF/WEBP transparency; VP9 carries alpha + "-b:v", "0", "-crf", strconv.Itoa(crf), // constant-quality mode + "-deadline", "realtime", "-cpu-used", "5", "-row-mt", "1", + "-f", "webm", + out, + ) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + // stderr goes to the log, never to the reply: it carries file paths and + // arbitrary demuxer output, and this command's refusals are held to the + // same "nothing internal reaches the user" rule as the download path. + log.Error("sticker_video_ffmpeg", "crf", crf, "err", err, "stderr", stderr.String()) + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, refuse("That video took too long to convert. Try a shorter or smaller clip.") + } + return nil, refuse("That video could not be turned into a sticker. Try a different clip.") + } + + data, err := os.ReadFile(out) + if err != nil { + return nil, fmt.Errorf("sticker video: read output: %w", err) + } + if len(data) == 0 { + // A zero-byte output with a zero exit status means the input carried no + // video stream at all — an audio file sent as a document, say. + return nil, refuse("That file has no video to convert.") + } + return data, nil +} diff --git a/internal/modules/util/sticker_video_test.go b/internal/modules/util/sticker_video_test.go new file mode 100644 index 0000000..63bb57b --- /dev/null +++ b/internal/modules/util/sticker_video_test.go @@ -0,0 +1,160 @@ +package util + +import ( + "bytes" + "context" + "errors" + "image" + "image/color" + "image/gif" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// requireFFmpeg skips when the host has no ffmpeg. The runtime image installs +// it (see the Dockerfile), but a developer machine or a CI runner may not have +// it, and a skipped test is better than a red one for a missing tool. +func requireFFmpeg(t *testing.T) { + t.Helper() + if _, err := exec.LookPath(ffmpegBinary); err != nil { + t.Skipf("%s not on PATH", ffmpegBinary) + } +} + +// makeAnimatedGIF builds a small multi-frame GIF in pure Go, so the *input* to +// the transcode needs no external tool. Deliberately not square and smaller +// than 512 on both sides, which is the case that exposes a scale filter that +// only ever shrinks. +func makeAnimatedGIF(t *testing.T, w, h, frames int) []byte { + t.Helper() + palette := color.Palette{color.Black, color.White, color.RGBA{R: 255, A: 255}} + g := &gif.GIF{} + for i := 0; i < frames; i++ { + img := image.NewPaletted(image.Rect(0, 0, w, h), palette) + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + // Shift the pattern per frame so the encoder has real motion to + // compress rather than identical frames it can drop. + img.SetColorIndex(x, y, uint8((x+y+i*7)%len(palette))) + } + } + g.Image = append(g.Image, img) + g.Delay = append(g.Delay, 10) + } + var buf bytes.Buffer + if err := gif.EncodeAll(&buf, g); err != nil { + t.Fatalf("encode gif: %v", err) + } + return buf.Bytes() +} + +// probe reads stream properties back out of a produced file. Returns ok=false +// when ffprobe is unavailable, leaving the caller to skip the detail checks. +func probe(t *testing.T, data []byte, entries string) (string, bool) { + t.Helper() + if _, err := exec.LookPath("ffprobe"); err != nil { + return "", false + } + path := filepath.Join(t.TempDir(), "probe.webm") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write probe input: %v", err) + } + out, err := exec.Command("ffprobe", "-v", "error", + "-show_entries", entries, "-of", "default=nw=1", path).Output() + if err != nil { + t.Fatalf("ffprobe: %v", err) + } + return string(out), true +} + +func TestToStickerWEBM_MeetsTelegramsVideoRules(t *testing.T) { + requireFFmpeg(t) + + // 100x50 and 12 frames: undersized on both axes, so the long edge must be + // scaled *up* to exactly 512. + src := makeAnimatedGIF(t, 100, 50, 12) + + out, err := toStickerWEBM(context.Background(), src) + if err != nil { + t.Fatalf("toStickerWEBM: %v", err) + } + if len(out) == 0 { + t.Fatal("toStickerWEBM returned no bytes") + } + if len(out) > maxStickerVideoBytes { + t.Errorf("output is %d bytes, past Telegram's %d-byte limit", len(out), maxStickerVideoBytes) + } + // EBML magic — the container really is Matroska/WebM, not whatever the + // muxer fell back to. + if !bytes.HasPrefix(out, []byte{0x1A, 0x45, 0xDF, 0xA3}) { + t.Errorf("output does not start with EBML magic: % x", out[:min(4, len(out))]) + } + + info, ok := probe(t, out, "stream=codec_name,codec_type,width,height:format=duration") + if !ok { + t.Log("ffprobe unavailable; skipped stream assertions") + return + } + + // One side exactly 512, the other 512 or less. + if !strings.Contains(info, "width=512") { + t.Errorf("want the long edge scaled to exactly 512; got %s", info) + } + if !strings.Contains(info, "height=256") { + t.Errorf("want the short edge to keep the 2:1 aspect; got %s", info) + } + if !strings.Contains(info, "codec_name=vp9") { + t.Errorf("want VP9; got %s", info) + } + // No audio stream: Telegram refuses a video sticker that carries one. + if strings.Contains(info, "codec_type=audio") { + t.Errorf("output carries an audio stream: %s", info) + } +} + +func TestToStickerWEBM_CapsDuration(t *testing.T) { + requireFFmpeg(t) + + // 10fps GIF with 80 frames is 8 seconds of source; the sticker must be cut + // to 3. + src := makeAnimatedGIF(t, 64, 64, 80) + + out, err := toStickerWEBM(context.Background(), src) + if err != nil { + t.Fatalf("toStickerWEBM: %v", err) + } + info, ok := probe(t, out, "format=duration") + if !ok { + t.Skip("ffprobe unavailable") + } + dur, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(info), "duration=")), 64) + if err != nil { + t.Fatalf("parse duration from %q: %v", info, err) + } + // A hair of slack for the final frame's presentation time. + if dur > maxStickerVideoSeconds+0.2 { + t.Errorf("duration = %.3fs, want at most %ds", dur, maxStickerVideoSeconds) + } +} + +// Garbage in must produce a refusal the user can read, never an internal error +// carrying ffmpeg's stderr. +func TestToStickerWEBM_RefusesNonVideo(t *testing.T) { + requireFFmpeg(t) + + _, err := toStickerWEBM(context.Background(), []byte("this is not a video")) + if err == nil { + t.Fatal("toStickerWEBM accepted non-video bytes") + } + var ue userError + if !errors.As(err, &ue) { + t.Fatalf("err = %v (%T), want a userError safe to show the caller", err, err) + } + if strings.Contains(ue.msg, "ffmpeg") { + t.Errorf("refusal mentions the tool: %q", ue.msg) + } +} diff --git a/internal/modules/util/util.go b/internal/modules/util/util.go index a1ee669..8f6404d 100644 --- a/internal/modules/util/util.go +++ b/internal/modules/util/util.go @@ -1,6 +1,7 @@ -// Package util implements /info, /help, /stickerid — the framework-validating -// "always on" module. /help is a pure renderer over the registry; the other -// two are debug helpers. +// Package util implements /info, /help, /stickerid, /addsticker — the +// framework-validating "always on" module. /help is a pure renderer over the +// registry, /info and /stickerid are debug helpers, and /addsticker appends a +// replied sticker or photo to one shared, env-configured sticker pack. package util import ( @@ -15,6 +16,7 @@ func New(deps modules.Deps) modules.Module { infoCommand(), helpCommand(deps.Registry), stickerIDCommand(), + addStickerCommand(), }, } }