feat(util): replace per-user sticker packs with one shared, self-creating pack

/addsticker becomes a single stateless command in util, writing to one
env-configured set (STICKER_PACK_NAME, default miti99_by_miti99bot).
AddStickerToSet takes the set owner's user ID rather than the caller's, so
nothing is per-user any more: the sticker module's pack records, slug
reservations, pending deletes, per-user locks and its eight other commands are
removed with it.

The pack creates itself on first use. A positive STICKERSET_INVALID from the
add triggers createNewStickerSet owned by OWNER_ID, seeded with the sticker
that triggered it and titled with the slug half of the name; a name that is
occupied but unwritable is reported instead of taken over. The mandatory
"_by_<bot_username>" suffix is Telegram's own proof of authorship, so a
misconfigured pack name is refused offline before any API call. StickerSet
exposes no owner ID, so ownership is only provable when Telegram refuses.

Video, GIF, animation and video-note sources are transcoded to WEBM/VP9 with
ffmpeg: long edge scaled to exactly 512 in either direction, cut to 3s, capped
at 30fps, audio dropped, retried down a CRF ladder until under 256KB. Animated
and video stickers are copied by file_id with no conversion. Sticker format is
per-sticker since Bot API 7.2, so one pack holds all three.

ffmpeg cannot ship in distroless/static and Go has no VP9 encoder, so the
runtime base becomes alpine with apk add ffmpeg. The image grows from roughly
20MB to 213MB, and the transcode holds the single dispatcher worker — bounded
at 20s per encode and a 45s handler deadline for moving sources, against 10s
for stills.
This commit is contained in:
2026-09-04 10:34:29 +07:00
parent f2a1012981
commit 28b1740fe4
41 changed files with 1872 additions and 4575 deletions
+9 -3
View File
@@ -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_<this bot username>: 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.
+13 -1
View File
@@ -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
+1 -2
View File
@@ -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 18002199 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 <username>`, `/stats cmd <command_name>` |
| `monkeyd` | `/monkeyd_crawl <url> [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags <url>` list its tags as hashtags |
| `sticker` | One personal sticker pack per user: `/newpack`, `/mypack`, `/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`, `/renamepack`, `/delpack`. See [docs/sticker-packs.md](docs/sticker-packs.md) |
Disable modules with the `MODULES` environment variable.
-8
View File
@@ -72,15 +72,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"monkeyd_crawl": "<url> [font_size]",
"monkeyd_tags": "<url>",
"random": "<option,...>",
"newpack": "<pack> <title...>",
"mypack": "",
"addsticker": "[emoji...]",
"delsticker": "",
"editsticker": "<emoji...>",
"ordersticker": "<position>",
"setpackicon": "",
"renamepack": "<title...>",
"delpack": "",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_events": "<ticker> [days]",
"stock_info": "<ticker>",
-2
View File
@@ -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,
}
}
+153 -133
View File
@@ -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` | `<pack> <title...>` | sticker or photo | Creates your pack and returns its share link |
| `/mypack` | — | — | Shows your pack: name, title, sticker count, link |
| `/addsticker` | `[emoji...]` | sticker or photo | Adds it to your pack |
| `/delsticker` | — | a sticker in your pack | Removes it |
| `/editsticker` | `<emoji...>` | a sticker in your pack | Replaces that sticker's emoji |
| `/ordersticker` | `<position>` | a sticker in your pack | Moves it; positions start at 0 |
| `/setpackicon` | — | a sticker in your pack | Uses it as the pack icon |
| `/renamepack` | `<title...>` | — | Changes the displayed title |
| `/delpack` | — | — | Deletes the pack, after an inline confirmation |
| `/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_<botusername>`.
| 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: 340 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_<this bot's username>`** — 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 120 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
3070 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 120 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/<name>_by_<bot>` is
publicly probeable without the bot, so the command discloses nothing new. It
never says *who* holds a name. Refusals about *managing* a pack are deliberately
uniform for the opposite reason — see below.
**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.
@@ -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)
}
}
@@ -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)
}
})
}
}
-123
View File
@@ -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: <CODE>", and the prose for the three could change without
// notice. Both forms are matched where they differ.
func apiRefusal(err error) (string, bool) {
if err == nil {
return "", false
}
text := err.Error()
switch {
case contains(text, "PACK_SHORT_NAME_OCCUPIED", "already occupied"):
return "That pack name is taken. Pick a different one.", true
case contains(text, "PACK_SHORT_NAME_INVALID", "invalid sticker set name"):
return "Telegram rejected that pack name. Use lowercase letters, digits and single underscores.", true
case contains(text, "PACK_TITLE_INVALID"):
return "Telegram rejected that title. Try a shorter, simpler one.", true
case contains(text, "STICKERSET_INVALID"):
return "Your pack no longer exists on Telegram. Use /newpack to create a new one.", true
case contains(text, "STICKERS_TOO_MUCH"):
return "Your pack is full (120 stickers).", true
case contains(text, "STICKER_EMOJI_INVALID", "invalid sticker emojis"):
return "Telegram rejected those emoji. Try different ones.", true
case contains(text, "too many emoji specified"):
return "At most 20 emoji per sticker.", true
case contains(text, "STICKER_PNG_DIMENSIONS", "STICKER_DIMENSIONS_INVALID"):
return "Telegram rejected that image's dimensions.", true
}
return "", false
}
// replyAPIError converts a Telegram API error into a reply. Errors with no
// specific mapping are logged and answered generically — the raw error never
// reaches the user.
func replyAPIError(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error {
if text, ok := apiRefusal(err); ok {
return reply(ctx, b, msg, text)
}
log.Error(op, "err", err)
return reply(ctx, b, msg, genericFailure)
}
func contains(text string, needles ...string) bool {
for _, n := range needles {
if strings.Contains(text, n) {
return true
}
}
return false
}
// createRefused reports whether err proves CreateNewStickerSet created nothing.
//
// Deliberately separate from apiRefusal even though today their code lists
// overlap. apiRefusal's job is "map an error to user-facing text"; this one's is
// "prove no set exists", which is what authorises releasing a name reservation
// and dropping a write-ahead intent. Reusing apiRefusal for both would mean the
// next person adding a code there for wording reasons silently converts it into
// a strand-the-slug bug.
//
// Every code here is a request-validation refusal: Telegram rejected the call
// before creating anything.
func createRefused(err error) bool {
if err == nil {
return false
}
return contains(err.Error(),
"PACK_SHORT_NAME_OCCUPIED", "already occupied",
"PACK_SHORT_NAME_INVALID", "invalid sticker set name",
"PACK_TITLE_INVALID",
"STICKER_EMOJI_INVALID", "invalid sticker emojis",
"too many emoji specified",
)
}
-440
View File
@@ -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")
}
}
-104
View File
@@ -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 "<slug>_by_<bot_username>", and stays bot-manageable because the
// bot created it.
//
// One pack per user is the central simplification: no command except /newpack
// takes a pack argument, because there is only ever one pack to act on.
package sticker
import (
"context"
"errors"
"strconv"
"github.com/tiennm99/miti99bot/internal/storage"
)
// Pack is the single bot-created sticker set owned by a Telegram user.
//
// The record is keyed by owner ID alone, which makes the lookup itself the
// ownership check: there is no way to read a pack without naming its owner.
type Pack struct {
Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL
Name string `bson:"name"` // Telegram set name, "<slug>_by_<botname>"
Title string `bson:"title"` // display title, mutable via /renamepack
OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to
Count int `bson:"count"` // stickers in the set; keeps /mypack API-free
Pending bool `bson:"pending"` // write-ahead intent; see newpack's state machine
CreatedAt int64 `bson:"createdAt"` // unix millis
}
// PackStore is the module's view over its collection.
type PackStore = storage.DocStore[Pack]
// SlugReservation records which user claimed a pack name, globally.
//
// Pack records are keyed by owner, so they can only answer "does *this* user
// have a pack" — they cannot answer "who holds this name". Without that second
// question the module cannot tell its own interrupted attempt from a set
// belonging to someone else, because both look identical from the caller's
// side: a pending record naming a set that exists.
//
// 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
}
-614
View File
@@ -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 <pack> <title...>\nEg: /newpack mypack My Pack"
// slugTaken answers a name held by anyone other than the caller. It says
// only "taken", never who holds it — the plan accepts that pack names are
// enumerable (share links are public), but there is no reason to confirm
// which are backed by real packs any more precisely than Telegram already
// does.
slugTaken = "That pack name is taken. Pick a different one."
noPackYet = "You don't have a pack yet. Reply to a sticker with /newpack <pack> <title...> to create one."
pendingMarker = "\n\n⚠️ This pack is incomplete — re-run the same /newpack command to finish it."
)
// handleNewPack creates the caller's pack.
//
// The hard part is not the API call, it is surviving an interruption. The slug
// fixes a permanent public URL, so an attempt that dies between "Telegram
// created the set" and "we wrote it down" would strand that slug forever: the
// set exists, nobody's record points at it, and the user cannot recreate it.
//
// The fix is a write-ahead record, claimed before Telegram is called, in two
// parts that answer two different questions:
//
// - a global reservation on the name — "who claimed this name?" (reserveSlug)
// - an owner-keyed pending Pack — "does this user have a pack?" (claimSlug)
//
// Both are needed. The pending record alone proves only that this caller *asked
// for* the name, which a user naming someone else's pack also does. 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 <title...>\nGive a title of 1-%d characters.", maxTitleLen))
}
pack, found, err := getPack(ctx, s.store, ownerID)
if err != nil {
log.Error("sticker_renamepack_load", "err", err)
return reply(ctx, b, msg, genericFailure)
}
if !found || pack.Pending {
return reply(ctx, b, msg, noPackYet)
}
defer s.lockUser(ownerID)()
if _, err := b.SetStickerSetTitle(ctx, &bot.SetStickerSetTitleParams{Name: pack.Name, Title: title}); err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_renamepack", err)
}
pack.Title = title
if err := s.commitPack(ctx, pack); err != nil {
// The rename already happened on Telegram's side; only our copy of the
// title is stale, and the next successful rename fixes it.
log.Error("sticker_renamepack_commit", "err", err)
}
// Naming the delete-and-recreate route turns a dead end into an answer.
// A user who typed "rename" with only a title is likely expecting the URL
// to follow, and it never can.
return reply(ctx, b, msg, fmt.Sprintf(
"Renamed to %s.\nThe link is unchanged: %s\n\nTo get a different link you have to /delpack and then /newpack under a new name — the stickers do not come along.",
title, shareLink(pack.Name)))
}
@@ -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)
}
}
-71
View File
@@ -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)
}
}
-103
View File
@@ -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')
}
-86
View File
@@ -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.")
}
-120
View File
@@ -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
}
-190
View File
@@ -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")
}
}
-39
View File
@@ -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
}
-63
View File
@@ -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)
}
}
}
-119
View File
@@ -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_<botusername>" suffix inside maxSetNameLen.
minSlugLen = 3
maxSlugLen = 40
)
// slugRe is the user-chosen half of a set name: 3-40 chars, starting with a
// letter. Telegram additionally forbids consecutive underscores, which a
// character class cannot express, so validateSlug checks that separately.
var slugRe = regexp.MustCompile(`^[a-z][a-z0-9_]{2,39}$`)
// validateSlug reports why a slug is unusable, or nil when it is fine.
//
// The slug is the one irreversible choice in this module: it fixes
// t.me/addstickers/<slug>_by_<bot> forever, because Telegram has no
// rename-short-name method. Rejecting loudly here is much cheaper than a user
// discovering the typo is permanent.
func validateSlug(slug string) error {
if !slugRe.MatchString(slug) {
return refuse(fmt.Sprintf("Pack name must be %d-%d characters: lowercase letters, digits and underscores, starting with a letter.", minSlugLen, maxSlugLen))
}
if strings.Contains(slug, "__") {
return refuse("Pack name cannot contain two underscores in a row.")
}
if strings.HasSuffix(slug, "_") {
return refuse("Pack name cannot end with an underscore.")
}
return nil
}
// makeSetName builds the Telegram set name for a new pack. It is used only at
// creation — never to resolve ownership, which compares the *stored* name (see
// ownsSet).
//
// The error reports the remaining budget rather than only refusing, because the
// only fix available to the user is a shorter slug and the limit depends on the
// bot's username length, which they cannot see.
func makeSetName(slug, botUsername string) (string, error) {
if botUsername == "" {
return "", errNoUsername
}
suffix := "_by_" + botUsername
if len(slug)+len(suffix) > maxSetNameLen {
budget := maxSetNameLen - len(suffix)
if budget > maxSlugLen {
budget = maxSlugLen
}
return "", refuse(fmt.Sprintf("Pack name is too long for this bot — use at most %d characters.", budget))
}
return slug + suffix, nil
}
// ownsSet reports whether setName is the caller's pack, comparing
// case-insensitively against the *stored* Pack.Name.
//
// It deliberately does not re-derive the name from the live bot username.
// Renaming the bot in BotFather is supported and leaves existing set names
// untouched, so a derived comparison would make every user's own pack refuse as
// "not yours" while /mypack still displayed it. Comparing the stored name also
// sidesteps casing: Telegram returns SetName with whatever casing the set was
// created with.
func ownsSet(pack Pack, setName string) bool {
if pack.Name == "" || setName == "" {
return false
}
return strings.EqualFold(pack.Name, setName)
}
// usernameResolver caches the bot's username for building new set names.
//
// The bot starts with bot.WithSkipGetMe(), so nothing populates a username
// until this asks. Failures are never cached: a transient GetMe error must not
// disable /newpack for the process's lifetime.
type usernameResolver struct {
mu sync.Mutex
username string
}
// resolve returns the bot's username, calling GetMe at most once per success.
// It takes the handler's *bot.Bot rather than Deps.Bot, which is documented
// nil-safe and is nil under BuildOptions{}.
func (r *usernameResolver) resolve(ctx context.Context, b *bot.Bot) (string, error) {
r.mu.Lock()
cached := r.username
r.mu.Unlock()
if cached != "" {
return cached, nil
}
me, err := b.GetMe(ctx)
if err != nil {
return "", err
}
if me == nil || me.Username == "" {
return "", errNoUsername
}
r.mu.Lock()
r.username = me.Username
r.mu.Unlock()
return me.Username, nil
}
-97
View File
@@ -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)
}
}
-56
View File
@@ -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.")
}
-127
View File
@@ -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."
-89
View File
@@ -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: "<pack> <title...>",
Handler: s.handleNewPack,
},
{
Name: "mypack",
Visibility: modules.VisibilityPublic,
Description: "Show your sticker pack and its link",
Handler: s.handleMyPack,
},
{
Name: "addsticker",
Visibility: modules.VisibilityPublic,
Description: "Add the replied sticker to your pack",
Parameters: "[emoji...]",
Handler: s.handleAddSticker,
},
{
Name: "delsticker",
Visibility: modules.VisibilityPublic,
Description: "Remove the replied sticker from your pack",
Handler: s.handleDelSticker,
},
{
Name: "editsticker",
Visibility: modules.VisibilityPublic,
Description: "Change the emoji of a sticker in your pack",
Parameters: "<emoji...>",
Handler: s.handleEditSticker,
},
{
Name: "ordersticker",
Visibility: modules.VisibilityPublic,
Description: "Move a sticker in your pack to a position",
Parameters: "<position>",
Handler: s.handleOrderSticker,
},
{
Name: "setpackicon",
Visibility: modules.VisibilityPublic,
Description: "Set your pack's icon from a sticker in it",
Handler: s.handleSetPackIcon,
},
{
Name: "renamepack",
Visibility: modules.VisibilityPublic,
Description: "Change your pack's title (the link cannot change)",
Parameters: "<title...>",
Handler: s.handleRenamePack,
},
{
Name: "delpack",
Visibility: modules.VisibilityPublic,
Description: "Delete your pack after confirmation",
Handler: s.handleDelPack,
},
},
Callbacks: []modules.Callback{{
Prefix: callbackPrefix,
Visibility: modules.VisibilityPublic,
Handler: s.handleDelPackCallback,
}},
}
}
@@ -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 <emoji...>\nReply to a sticker in your pack with at least one emoji.")
}
owned, err := s.resolveOwned(ctx, msg, ownerID)
if err != nil {
return replyErr(ctx, b, msg, "sticker_editsticker_resolve", err)
}
if _, err := b.SetStickerEmojiList(ctx, &bot.SetStickerEmojiListParams{
Sticker: owned.fileID,
EmojiList: emoji,
}); err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_editsticker", err)
}
return reply(ctx, b, msg, "Emoji updated.")
}
// handleOrderSticker moves a sticker to a new position in the caller's pack.
func (s *state) handleOrderSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
args := commandArgs(msg)
if len(args) != 1 {
return reply(ctx, b, msg, "Usage: /ordersticker <position>\nReply to a sticker in your pack. Positions start at 0.")
}
pos, err := strconv.Atoi(args[0])
if err != nil || pos < 0 {
// Only the lower bound is checked locally. The upper bound is the set's
// current size, which Telegram knows and a local copy would not.
return reply(ctx, b, msg, "Position must be a whole number, 0 or greater.")
}
owned, err := s.resolveOwned(ctx, msg, ownerID)
if err != nil {
return replyErr(ctx, b, msg, "sticker_ordersticker_resolve", err)
}
if _, err := b.SetStickerPositionInSet(ctx, &bot.SetStickerPositionInSetParams{
Sticker: owned.fileID,
Position: pos,
}); err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_ordersticker", err)
}
return reply(ctx, b, msg, fmt.Sprintf("Moved to position %d.", pos))
}
+374
View File
@@ -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)
}
@@ -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_<bot_username>" 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 <args>" 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_<bot>".
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_<bot_username>" 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: <CODE>" 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)
})
}
}
@@ -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
@@ -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)
}
@@ -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)
@@ -1,4 +1,4 @@
package sticker
package util
import (
"strings"
@@ -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) {
@@ -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) {
+317
View File
@@ -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_<bot_username>" 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_<bot_username>" 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: <CODE>", 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
}
+151
View File
@@ -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 <bot_username> 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")
}
}
@@ -1,4 +1,4 @@
package sticker
package util
import (
"testing"
@@ -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)
}
}
+151
View File
@@ -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
}
+160
View File
@@ -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)
}
}
+5 -3
View File
@@ -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(),
},
}
}