mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-03 18:16:56 +00:00
Merge pull request #4 from tiennm99/feature/sticker-pack-module
feat(sticker): sticker pack module
This commit is contained in:
+5
-2
@@ -12,8 +12,11 @@ MONGO_URL=mongodb+srv://botuser:STRONG_UNIQUE_PASSWORD@cluster0.xxxxx.mongodb.ne
|
||||
MONGO_DATABASE=miti99bot
|
||||
|
||||
# ============================ Operational =========================
|
||||
# Comma-separated module list. Empty = load every module.
|
||||
MODULES=
|
||||
# Comma-separated module list. Empty = load every module, including any module
|
||||
# added later — so list them explicitly when a deployment should only gain a new
|
||||
# module deliberately. `sticker` creates real, durable Telegram sticker sets on
|
||||
# behalf of users, which is worth enabling on purpose rather than by default.
|
||||
MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker
|
||||
# Telegram user id for owner-only commands (renamed from BOT_OWNER_ID).
|
||||
OWNER_ID=
|
||||
# Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS).
|
||||
|
||||
@@ -18,6 +18,7 @@ Atlas via long polling and an in-process cron scheduler.
|
||||
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
|
||||
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <username>`, `/stats cmd <command_name>` |
|
||||
| `monkeyd` | `/monkeyd_crawl <url> [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags <url>` list its tags as hashtags |
|
||||
| `sticker` | One personal sticker pack per user: `/newpack`, `/mypack`, `/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`, `/renamepack`, `/delpack`. See [docs/sticker-packs.md](docs/sticker-packs.md) |
|
||||
|
||||
Disable modules with the `MODULES` environment variable.
|
||||
|
||||
|
||||
@@ -72,6 +72,15 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
"monkeyd_crawl": "<url> [font_size]",
|
||||
"monkeyd_tags": "<url>",
|
||||
"random": "<option,...>",
|
||||
"newpack": "<pack> <title...>",
|
||||
"mypack": "",
|
||||
"addsticker": "[emoji...]",
|
||||
"delsticker": "",
|
||||
"editsticker": "<emoji...>",
|
||||
"ordersticker": "<position>",
|
||||
"setpackicon": "",
|
||||
"renamepack": "<title...>",
|
||||
"delpack": "",
|
||||
"stats": "[users | user <username> | cmd <command_name>]",
|
||||
"stock_events": "<ticker> [days]",
|
||||
"stock_info": "<ticker>",
|
||||
@@ -91,7 +100,11 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
if len(menu) != len(reg.PublicCommands()) {
|
||||
t.Fatalf("menu commands = %d, public commands = %d", len(menu), len(reg.PublicCommands()))
|
||||
}
|
||||
// This map lists the commands whose parameter strings are pinned; commands
|
||||
// absent from it are not asserted (the lookup yields "" for them).
|
||||
seen := map[string]bool{}
|
||||
for _, command := range reg.PublicCommands() {
|
||||
seen[command.Name] = true
|
||||
if got := command.Parameters; got != expectedParameters[command.Name] {
|
||||
t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name])
|
||||
}
|
||||
@@ -107,6 +120,20 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every expectation must correspond to a registered command.
|
||||
//
|
||||
// Without this, an entry whose command stopped being registered simply
|
||||
// stopped being checked: the forward loop only visits commands that exist,
|
||||
// so removing a whole module from factories() left the suite green. That is
|
||||
// also what makes the parameterless entries above load-bearing rather than
|
||||
// decorative — "" == "" asserts nothing on its own, but the command having
|
||||
// to exist at all does.
|
||||
for name := range expectedParameters {
|
||||
if !seen[name] {
|
||||
t.Errorf("/%s is expected but not registered — a module dropped out of factories()", name)
|
||||
}
|
||||
}
|
||||
|
||||
help := moduleutil.RenderHelp(reg)
|
||||
if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest {
|
||||
t.Fatalf("/help source is %d characters, exceeds conservative Telegram limit %d", utf8.RuneCountInString(help), telegramMessageMaxRunesForTest)
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/modules/misc"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/monkeyd"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/stats"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/sticker"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/stock"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/wordle"
|
||||
@@ -91,6 +92,7 @@ func factories() map[string]modules.Factory {
|
||||
"gold": gold.New,
|
||||
stock.CollectionName: stock.New,
|
||||
"stats": stats.New,
|
||||
"sticker": sticker.New,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ remain responsible for parsing and validation.
|
||||
|---|---|---|
|
||||
| Required value | `<name>` | `<ticker>` |
|
||||
| Required comma-separated values | `<name,...>` | `<option,...>` |
|
||||
| Required remaining text | `<name...>` | `<title...>` |
|
||||
| Optional value | `[name]` | `[date]` |
|
||||
| Optional remaining text | `[name...]` | `[target...]` |
|
||||
| Alternatives in an optional group | `[literal | literal <name>]` | `[users | user <username>]` |
|
||||
@@ -40,6 +41,7 @@ language or extra punctuation without a user-facing need.
|
||||
|
||||
```text
|
||||
/stock_buy <quantity> <ticker>
|
||||
/renamepack <title...>
|
||||
/lol [date]
|
||||
/trongtruonghop [target...]
|
||||
/stats [users | user <username> | cmd <command_name>]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# Sticker packs
|
||||
|
||||
The `sticker` module lets any user create and manage **one** personal Telegram
|
||||
sticker pack through the bot. The pack is created on behalf of the calling user,
|
||||
so it appears under their account, and it stays bot-manageable because the bot
|
||||
created it.
|
||||
|
||||
Every command is single-shot: one message carrying its arguments, optionally
|
||||
replying to a sticker or photo. There is no conversation state and no `/cancel`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Parameters | Reply to | What it does |
|
||||
|---|---|---|---|
|
||||
| `/newpack` | `<pack> <title...>` | sticker or photo | Creates your pack and returns its share link |
|
||||
| `/mypack` | — | — | Shows your pack: name, title, sticker count, link |
|
||||
| `/addsticker` | `[emoji...]` | sticker or photo | Adds it to your pack |
|
||||
| `/delsticker` | — | a sticker in your pack | Removes it |
|
||||
| `/editsticker` | `<emoji...>` | a sticker in your pack | Replaces that sticker's emoji |
|
||||
| `/ordersticker` | `<position>` | a sticker in your pack | Moves it; positions start at 0 |
|
||||
| `/setpackicon` | — | a sticker in your pack | Uses it as the pack icon |
|
||||
| `/renamepack` | `<title...>` | — | Changes the displayed title |
|
||||
| `/delpack` | — | — | Deletes the pack, after an inline confirmation |
|
||||
|
||||
Only `/newpack` names a pack. Every other command resolves your single pack from
|
||||
storage, or from the replied sticker's set.
|
||||
|
||||
## The pack name is permanent
|
||||
|
||||
`/newpack mypack My Pack` creates `t.me/addstickers/mypack_by_<botusername>`.
|
||||
|
||||
**Telegram has no method to rename a sticker set's short name.** That link is
|
||||
fixed for the life of the pack. `/renamepack` changes only the displayed title.
|
||||
|
||||
The only way to a different link is `/delpack` followed by `/newpack` under a new
|
||||
name — and the stickers do not come along. `/delpack`'s confirmation states the
|
||||
title, the number of stickers it destroys, the exact link being surrendered, and
|
||||
that both are permanent, because that prompt is the last point at which someone
|
||||
wanting "a rename" learns what it actually costs.
|
||||
|
||||
Pack-name rules: 3–40 characters, lowercase letters, digits and underscores,
|
||||
starting with a letter, no two underscores in a row, no trailing underscore.
|
||||
|
||||
## Images
|
||||
|
||||
Static stickers only. Animated, video, mask, and custom-emoji stickers are
|
||||
rejected.
|
||||
|
||||
- A replied **sticker** is added directly.
|
||||
- A replied **photo** or image **document** (`image/png`, `image/jpeg`,
|
||||
`image/webp`) is downloaded, resized so its long edge is exactly 512px with
|
||||
the aspect ratio preserved, and uploaded as a PNG.
|
||||
- Pack icons are resized to exactly 100×100, padded transparently.
|
||||
- Sources above 2 MB, or with either side above 4096px, are rejected.
|
||||
|
||||
Telegram allows 120 stickers per pack and 1–20 emoji per sticker. There is no
|
||||
documented file-size limit for static stickers; the module applies its own
|
||||
client-side ceiling and never presents it as a Telegram rule.
|
||||
|
||||
## Who can use it
|
||||
|
||||
Public — every user manages their own pack.
|
||||
|
||||
**Anonymous group admins are refused.** Telegram substitutes a single global
|
||||
`GroupAnonymousBot` user for every anonymous admin message, so without this
|
||||
refusal all anonymous admins across all groups would share one pack. Turn off
|
||||
anonymous posting for the message and try again.
|
||||
|
||||
## Deliberate omissions
|
||||
|
||||
- **Usage statistics** (`/stats`, `/top`, `/packstats`, …) — the Bot API does
|
||||
not expose sticker usage counts, and `/stats` belongs to the `stats` module.
|
||||
- **Animated, video, emoji, and mask packs** — out of scope; this module is
|
||||
static-only.
|
||||
- **More than one pack per user** — a deliberate simplification. It is what lets
|
||||
every command but `/newpack` drop its pack argument.
|
||||
- **`/cancel`** — meaningless without conversation state.
|
||||
- **A `/repack` migration command** — copying a full pack is up to ~121
|
||||
sequential API calls, which would stall the bot for every user.
|
||||
|
||||
## Behaviour worth knowing
|
||||
|
||||
**Sticker counts are advisory.** `/mypack` reads the count from storage and
|
||||
makes no API calls at all. Editing your pack through @Stickers changes the real
|
||||
count without the bot seeing it; the number re-syncs whenever a command already
|
||||
has a fresh view of the set.
|
||||
|
||||
**Pack names are claimed first-come and held permanently.** The bot records who
|
||||
claimed each name before it creates anything on Telegram, and only that user can
|
||||
ever manage a pack under it. This is what stops someone from reading a pack's
|
||||
name off its public link and taking it over.
|
||||
|
||||
`/newpack` therefore reports when a name is taken, which reveals that some user
|
||||
of this bot holds it. That is accepted: `t.me/addstickers/<name>_by_<bot>` is
|
||||
publicly probeable without the bot, so the command discloses nothing new. It
|
||||
never says *who* holds a name. Refusals about *managing* a pack are deliberately
|
||||
uniform for the opposite reason — see below.
|
||||
|
||||
A name is claimed *before* the bot calls Telegram, not after the pack exists.
|
||||
That ordering is 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.
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.43.0
|
||||
github.com/tiennm99/monkeyd-crawler v0.0.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.7.0
|
||||
golang.org/x/image v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -71,7 +72,7 @@ require (
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -165,8 +167,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
@@ -2,6 +2,7 @@ package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -71,6 +72,7 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) {
|
||||
return matchCommand(nameCopy, update)
|
||||
},
|
||||
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
||||
defer recoverHandler("command", cmdCopy.Name, nil)
|
||||
if !auth.Permits(cmdCopy.Visibility, update) {
|
||||
return // silent — do not leak existence of gated commands
|
||||
}
|
||||
@@ -78,6 +80,10 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) {
|
||||
// context.Background is intentional: the hook must outlive the request
|
||||
// context so stats writes complete even after the handler returns.
|
||||
go func() { //nolint:gosec // G118: goroutine intentionally detached from request context
|
||||
// This goroutine is outside the handler's barrier above, so
|
||||
// it needs its own: a panicking hook on its own goroutine
|
||||
// still terminates the process.
|
||||
defer recoverHandler("command hook", cmdCopy.Name, nil)
|
||||
hookCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
reg.RunCommandHooks(hookCtx, cmdCopy.Name, update)
|
||||
@@ -95,6 +101,11 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) {
|
||||
prefixCopy := prefix
|
||||
b.RegisterHandler(bot.HandlerTypeCallbackQueryData, prefixCopy, bot.MatchTypePrefix,
|
||||
func(ctx context.Context, b *bot.Bot, update *models.Update) {
|
||||
defer recoverHandler("callback", prefixCopy, func() {
|
||||
if update != nil && update.CallbackQuery != nil {
|
||||
_, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID})
|
||||
}
|
||||
})
|
||||
if !auth.Permits(callbackCopy.Visibility, update) {
|
||||
if update != nil && update.CallbackQuery != nil {
|
||||
_, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID})
|
||||
@@ -110,6 +121,34 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) {
|
||||
}
|
||||
}
|
||||
|
||||
// recoverHandler contains a panic raised by a module handler. The bot runs with
|
||||
// bot.WithNotAsyncHandlers() and a single worker, so the handler executes inline
|
||||
// on the polling goroutine: without this barrier one panicking handler ends the
|
||||
// process for every user. Mirrors the barrier the cron scheduler already puts
|
||||
// around its handlers (internal/cron/scheduler.go).
|
||||
//
|
||||
// The panic is logged at ERROR with a full stack and counted under a distinct
|
||||
// handler-panic metric, so a handler that panics on every call is loud rather
|
||||
// than quietly failing per-request.
|
||||
//
|
||||
// onPanic, when non-nil, runs after logging — the callback path uses it to
|
||||
// answer the query so the caller's client stops showing a spinner. It is itself
|
||||
// guarded, because a panic raised inside the recovery path would have no
|
||||
// remaining barrier.
|
||||
func recoverHandler(kind, name string, onPanic func()) {
|
||||
rec := recover()
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
metrics.IncError("handler-panic")
|
||||
log.Error(kind+" panic", kind, name, "panic", rec, "stack", string(debug.Stack()))
|
||||
if onPanic == nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = recover() }()
|
||||
onPanic()
|
||||
}
|
||||
|
||||
// logCommand emits one structured line per authorized command invocation: what
|
||||
// was typed (input), who sent it (user id + @username), where (DM vs group, with
|
||||
// chat id and — for groups — the title), and the outcome. The result is kept
|
||||
@@ -163,8 +202,9 @@ func matchCommand(name string, update *models.Update) bool {
|
||||
continue
|
||||
}
|
||||
// Bounds check: defensive against malformed entities from a future
|
||||
// API revision; the library's match func omits this so a bad entity
|
||||
// would panic the goroutine before our recover() in webhook.go.
|
||||
// API revision. The library's match func omits it, and a matcher runs
|
||||
// outside Install's panic barrier, so a bad entity would panic the
|
||||
// polling goroutine with nothing to catch it.
|
||||
end := e.Offset + e.Length
|
||||
if e.Offset < 0 || end > len(text) || e.Length < 1 {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/metrics"
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
// The bot dispatches updates inline on a single polling goroutine
|
||||
// (bot.WithNotAsyncHandlers), so an unrecovered handler panic takes the whole
|
||||
// process down for every user — not just the caller who triggered it. These
|
||||
// tests pin the barrier that stops that: the panic is contained, logged with a
|
||||
// stack, and counted, and the process (here, the test binary) survives.
|
||||
|
||||
// syncBuffer is a log sink safe to read while another goroutine writes.
|
||||
//
|
||||
// slog is concurrency-safe, but the *sink* it writes into is the test's
|
||||
// responsibility — and one of these tests deliberately provokes a panic on a
|
||||
// detached goroutine, which then logs while the test reads.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// captureLogs redirects the default logger for the duration of fn and returns
|
||||
// everything written. Mirrors TestLogCommand's idiom.
|
||||
func captureLogs(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
buf := &syncBuffer{}
|
||||
prev := log.Default()
|
||||
log.SetDefault(slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer log.SetDefault(prev)
|
||||
fn()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// waitForLog polls buf until it contains needle, so a test never races a
|
||||
// detached goroutine with a fixed sleep.
|
||||
func waitForLog(t *testing.T, buf *syncBuffer, needle string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(buf.String(), needle) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %q; log was %q", needle, buf.String())
|
||||
}
|
||||
|
||||
// installPanicking builds a registry with one command and one callback that
|
||||
// both panic, and installs it on a recording bot.
|
||||
func installPanicking(t *testing.T) *testutil.RecordingBot {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"boom"}, map[string]modules.Factory{
|
||||
"boom": func(modules.Deps) modules.Module {
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{{
|
||||
Name: "boom",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "panics on purpose",
|
||||
Handler: func(context.Context, *bot.Bot, *models.Update) error {
|
||||
panic("command exploded")
|
||||
},
|
||||
}},
|
||||
Callbacks: []modules.Callback{{
|
||||
Prefix: "boom:",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Handler: func(context.Context, *bot.Bot, *models.Update) error {
|
||||
panic("callback exploded")
|
||||
},
|
||||
}},
|
||||
}
|
||||
},
|
||||
}, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("build registry: %v", err)
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
return rb
|
||||
}
|
||||
|
||||
func TestInstall_CommandPanicIsContained(t *testing.T) {
|
||||
rb := installPanicking(t)
|
||||
|
||||
// The assertion is that this line returns at all: without the barrier the
|
||||
// panic unwinds through ProcessUpdate and kills the test binary.
|
||||
logs := captureLogs(t, func() {
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/boom"))
|
||||
metrics.Flush()
|
||||
})
|
||||
|
||||
if !strings.Contains(logs, "command panic") {
|
||||
t.Errorf("panic not logged; got %q", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "command exploded") {
|
||||
t.Errorf("panic value not logged; got %q", logs)
|
||||
}
|
||||
if !strings.Contains(logs, "stack") {
|
||||
t.Errorf("stack not logged; got %q", logs)
|
||||
}
|
||||
// metrics.Flush renders the error counters into its log line, which is the
|
||||
// only view of them from outside the metrics package.
|
||||
if !strings.Contains(logs, "handler-panic") {
|
||||
t.Errorf("handler-panic metric not incremented; got %q", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_CallbackPanicIsContainedAndAnswered(t *testing.T) {
|
||||
rb := installPanicking(t)
|
||||
|
||||
update := &models.Update{CallbackQuery: &models.CallbackQuery{
|
||||
ID: "cbq-1",
|
||||
From: models.User{ID: 42},
|
||||
Data: "boom:go",
|
||||
}}
|
||||
|
||||
logs := captureLogs(t, func() {
|
||||
rb.Bot.ProcessUpdate(context.Background(), update)
|
||||
})
|
||||
|
||||
if !strings.Contains(logs, "callback panic") {
|
||||
t.Errorf("panic not logged; got %q", logs)
|
||||
}
|
||||
|
||||
// A panicking callback must still answer the query, or the caller's client
|
||||
// spins until it times out.
|
||||
var answered bool
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method == "answerCallbackQuery" && call.Form["callback_query_id"] == "cbq-1" {
|
||||
answered = true
|
||||
}
|
||||
}
|
||||
if !answered {
|
||||
t.Errorf("callback query not answered after panic; sent %+v", rb.Sent())
|
||||
}
|
||||
}
|
||||
|
||||
// The stats module registers a CommandHook, which runs on a detached goroutine
|
||||
// outside the handler's barrier. A panic there has nothing above it on that
|
||||
// goroutine's stack, so it terminates the process regardless of how well the
|
||||
// handler itself is protected.
|
||||
func TestInstall_CommandHookPanicIsContained(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"hooky"}, map[string]modules.Factory{
|
||||
"hooky": func(modules.Deps) modules.Module {
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{{
|
||||
Name: "hooky",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "fine itself; its hook is not",
|
||||
Handler: func(context.Context, *bot.Bot, *models.Update) error {
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
CommandHook: func(context.Context, string, *models.Update) {
|
||||
panic("hook exploded")
|
||||
},
|
||||
}
|
||||
},
|
||||
}, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("build registry: %v", err)
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
|
||||
buf := &syncBuffer{}
|
||||
prev := log.Default()
|
||||
log.SetDefault(slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer log.SetDefault(prev)
|
||||
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/hooky"))
|
||||
|
||||
// The assertion is that the test binary is still alive to run it: without
|
||||
// the barrier, the hook's panic has nothing above it on its own goroutine.
|
||||
waitForLog(t, buf, "command hook panic")
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
// handleDelPack asks for confirmation before destroying the caller's pack.
|
||||
//
|
||||
// /delpack is also the *only* way to change a pack's URL, since Telegram has no
|
||||
// rename-short-name method. That makes this prompt the last point at which a
|
||||
// user who came here wanting a new link learns that the stickers do not survive
|
||||
// the change — so it states the title, the count being lost, the exact link
|
||||
// being surrendered, and that both are permanent.
|
||||
func (s *state) handleDelPack(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
ctx, cancel := handlerContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
msg := update.Message
|
||||
ownerID, err := senderID(msg)
|
||||
if err != nil {
|
||||
return reply(ctx, b, msg, senderRefusal)
|
||||
}
|
||||
|
||||
pack, found, err := getPack(ctx, s.store, ownerID)
|
||||
if err != nil {
|
||||
log.Error("sticker_delpack_load", "err", err)
|
||||
return reply(ctx, b, msg, genericFailure)
|
||||
}
|
||||
if !found {
|
||||
return reply(ctx, b, msg, noPackYet)
|
||||
}
|
||||
|
||||
// A pending record is bookkeeping, not proof that this bot created a set
|
||||
// under that name on this user's behalf — /newpack writes it before
|
||||
// Telegram is called, and anyone can make one naming any set. Deleting by
|
||||
// set name is authorised by Telegram for every set this bot created, so
|
||||
// confirming a delete from a pending record would let one user destroy
|
||||
// another's pack. Clear the local record only, and touch nothing upstream.
|
||||
if pack.Pending {
|
||||
defer s.lockUser(ownerID)()
|
||||
s.dropPackRecord(ctx, ownerID)
|
||||
return reply(ctx, b, msg, fmt.Sprintf(
|
||||
"Cleared an unfinished attempt at %s and freed the name. Nothing was deleted at Telegram; if that attempt did create a pack, it is no longer reachable through this bot.",
|
||||
pack.Slug))
|
||||
}
|
||||
|
||||
id, err := newActionID()
|
||||
if err != nil {
|
||||
log.Error("sticker_delpack_id", "err", err)
|
||||
return reply(ctx, b, msg, genericFailure)
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
action := PendingDelete{
|
||||
ID: id,
|
||||
OwnerID: ownerID,
|
||||
Slug: pack.Slug,
|
||||
SetName: pack.Name,
|
||||
ChatID: msg.Chat.ID,
|
||||
CreatedAt: now.UnixMilli(),
|
||||
ExpiresAt: now.Add(pendingDeleteTTL).UnixMilli(),
|
||||
}
|
||||
|
||||
sent, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
ReplyParameters: &models.ReplyParameters{MessageID: msg.ID},
|
||||
Text: fmt.Sprintf(
|
||||
"Delete %s (%s)?\n\nThis destroys %d sticker(s) and gives up %s permanently. Neither can be recovered, and the link may not be reusable.",
|
||||
pack.Title, pack.Slug, pack.Count, shareLink(pack.Name)),
|
||||
ReplyMarkup: &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{{
|
||||
{Text: "Delete permanently", CallbackData: deleteCallbackData(id)},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("sticker_delpack_prompt", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Bind the action to the message carrying the button, so a press from a
|
||||
// forwarded or replayed copy resolves to nothing.
|
||||
action.MessageID = sent.ID
|
||||
commitCtx, cancelCommit := commitContext(ctx)
|
||||
defer cancelCommit()
|
||||
if err := s.pending.Put(commitCtx, pendingDeleteKey(ownerID), action); err != nil {
|
||||
log.Error("sticker_delpack_store", "err", err)
|
||||
return reply(ctx, b, msg, genericFailure)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDelPackCallback consumes a confirm press.
|
||||
func (s *state) handleDelPackCallback(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
ctx, cancel := handlerContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
if update == nil || update.CallbackQuery == nil {
|
||||
return nil
|
||||
}
|
||||
query := update.CallbackQuery
|
||||
|
||||
id, ok := parseDeleteCallback(query.Data)
|
||||
if !ok {
|
||||
return answerCallback(ctx, b, query.ID, "This confirmation is invalid.")
|
||||
}
|
||||
|
||||
// The lookup is keyed by the presser, never by the payload: the payload is
|
||||
// client-controlled, so using it to choose *whose* action to load would let
|
||||
// anyone address someone else's confirmation.
|
||||
if query.From.ID == 0 {
|
||||
return answerCallback(ctx, b, query.ID, "This confirmation is invalid.")
|
||||
}
|
||||
key := pendingDeleteKey(query.From.ID)
|
||||
action, _, err := s.pending.Get(ctx, key)
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return answerCallback(ctx, b, query.ID, "This confirmation expired or was already used.")
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("sticker_delpack_action_load", "err", err)
|
||||
return answerCallback(ctx, b, query.ID, "Could not load this confirmation. Try /delpack again.")
|
||||
}
|
||||
|
||||
// Unreachable by construction — the action was loaded under this presser's
|
||||
// own key, so a foreign press already returned above. Kept as defence in
|
||||
// depth against a future change to how actions are addressed.
|
||||
if query.From.ID != action.OwnerID {
|
||||
return answerCallback(ctx, b, query.ID, "Only the user who ran /delpack can confirm it.")
|
||||
}
|
||||
|
||||
// CallbackQuery.Message is a MaybeInaccessibleMessage: nil for messages
|
||||
// Telegram considers inaccessible. The panic barrier is the backstop, not a
|
||||
// reason to skip the guard — and this must come before any use of msg.
|
||||
msg := query.Message.Message
|
||||
if msg == nil {
|
||||
return answerCallback(ctx, b, query.ID, "This confirmation is no longer valid here.")
|
||||
}
|
||||
|
||||
// Binding first, and with no side effect. This press may be on somebody
|
||||
// else's prompt: anyone in a group can tap anyone's button, so touching the
|
||||
// message before proving it is the one this action was written for let a
|
||||
// bystander strip the button off a live confirmation they had no part in.
|
||||
//
|
||||
// It also subsumes the stale-prompt case — an older prompt is a different
|
||||
// message id, so it fails here.
|
||||
if msg.Chat.ID != action.ChatID || msg.ID != action.MessageID || action.MessageID == 0 {
|
||||
return answerCallback(ctx, b, query.ID, "This confirmation is no longer valid here.")
|
||||
}
|
||||
|
||||
// Defence in depth: the binding above already implies this, since a newer
|
||||
// /delpack writes a new message id. Clearing is safe here only because the
|
||||
// binding proved this is the caller's own bound message.
|
||||
if action.ID != id {
|
||||
clearButton(ctx, b, msg.Chat.ID, msg.ID)
|
||||
return answerCallback(ctx, b, query.ID, "This confirmation was replaced by a newer /delpack.")
|
||||
}
|
||||
|
||||
if action.ExpiresAt <= s.now().UnixMilli() {
|
||||
s.dropPendingDelete(ctx, key)
|
||||
clearButton(ctx, b, action.ChatID, action.MessageID)
|
||||
return answerCallback(ctx, b, query.ID, "This confirmation expired. Run /delpack again.")
|
||||
}
|
||||
|
||||
defer s.lockUser(action.OwnerID)()
|
||||
|
||||
// Re-establish, under the lock, that the caller still holds this exact set.
|
||||
//
|
||||
// The authority to delete comes from the record, not from the prompt, and a
|
||||
// prompt outlives the record: /delpack can sit unpressed for ten minutes
|
||||
// while the pack disappears from Telegram's side, a self-heal frees the
|
||||
// name, and somebody else claims it. DeleteStickerSet is keyed by set name
|
||||
// and Telegram authorises it for every set this bot created, so a press
|
||||
// then lands on whoever holds the name at that moment.
|
||||
//
|
||||
// Stated as an allowlist deliberately. The first version of this guard
|
||||
// listed the states it would refuse — a pending record still naming this
|
||||
// set — and fell through on the two that mattered: no record at all, and a
|
||||
// record that had moved on to a different pack. Proving authority is the
|
||||
// only formulation that fails closed against a state nobody thought of.
|
||||
current, found, err := getPack(ctx, s.store, action.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("sticker_delpack_recheck", "err", err)
|
||||
return answerCallback(ctx, b, query.ID, "Could not confirm right now. Try /delpack again.")
|
||||
}
|
||||
// !found is stated explicitly even though ownsSet already returns false for
|
||||
// a zero-value record's empty Name — mutation testing shows it is currently
|
||||
// redundant. It stays because that redundancy is an accident of ownsSet's
|
||||
// empty-string guard, not something this check should depend on.
|
||||
if !found || current.Pending || !ownsSet(current, action.SetName) {
|
||||
s.dropPendingDelete(ctx, key)
|
||||
clearButton(ctx, b, action.ChatID, action.MessageID)
|
||||
return answerCallback(ctx, b, query.ID,
|
||||
"This confirmation is out of date — that pack is no longer yours to delete. Run /delpack again if you still want to.")
|
||||
}
|
||||
|
||||
// Consume the action *before* the destructive call, so a double press
|
||||
// cannot delete twice or race a second confirmation.
|
||||
if err := s.pending.Delete(ctx, key); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return answerCallback(ctx, b, query.ID, "This confirmation was already used.")
|
||||
}
|
||||
log.Error("sticker_delpack_consume", "err", err)
|
||||
return answerCallback(ctx, b, query.ID, "Could not confirm right now. Try /delpack again.")
|
||||
}
|
||||
|
||||
_, err = b.DeleteStickerSet(ctx, &bot.DeleteStickerSetParams{Name: action.SetName})
|
||||
switch {
|
||||
case err == nil, isStickerSetMissing(err):
|
||||
// Missing counts as success: the set is gone either way, and clearing
|
||||
// the record is what unblocks /newpack.
|
||||
//
|
||||
// But clear it only if it still names *this* set. dropPackRecord deletes
|
||||
// by owner, and the user's record may have moved on to a different pack
|
||||
// since this confirmation was written — that is exactly what the
|
||||
// documented /delpack-then-/newpack URL-change route does. Deleting
|
||||
// blindly by owner would then erase a live pack's record.
|
||||
s.dropPackRecordIfSet(ctx, action.OwnerID, action.SetName)
|
||||
clearButton(ctx, b, action.ChatID, action.MessageID)
|
||||
// Reply to the prompt rather than sending bare to the chat: in a forum
|
||||
// supergroup a bare ChatID send lands in General instead of the topic
|
||||
// the button lives in, leaking the pack name across topics and losing
|
||||
// the confirmation. chathelper.Reply carries MessageThreadID.
|
||||
_ = reply(ctx, b, msg, fmt.Sprintf("Deleted %s. You can create a new pack with /newpack.", action.Slug))
|
||||
return answerCallback(ctx, b, query.ID, "Pack deleted.")
|
||||
|
||||
default:
|
||||
// The record stays: we have no positive signal that the set is gone.
|
||||
log.Error("sticker_delpack_delete", "err", err)
|
||||
clearButton(ctx, b, action.ChatID, action.MessageID)
|
||||
return answerCallback(ctx, b, query.ID, "Telegram refused the delete. Your pack is unchanged.")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *state) dropPendingDelete(ctx context.Context, key string) {
|
||||
commitCtx, cancel := commitContext(ctx)
|
||||
defer cancel()
|
||||
if err := s.pending.Delete(commitCtx, key); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
log.Error("sticker_drop_pending_delete", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func answerCallback(ctx context.Context, b *bot.Bot, queryID, text string) error {
|
||||
_, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{
|
||||
CallbackQueryID: queryID,
|
||||
Text: text,
|
||||
ShowAlert: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// clearButton removes the inline keyboard so a spent prompt cannot be pressed
|
||||
// again. Best effort: the action is already consumed either way.
|
||||
func clearButton(ctx context.Context, b *bot.Bot, chatID int64, messageID int) {
|
||||
_, err := b.EditMessageReplyMarkup(ctx, &bot.EditMessageReplyMarkupParams{
|
||||
ChatID: chatID,
|
||||
MessageID: messageID,
|
||||
ReplyMarkup: &models.InlineKeyboardMarkup{},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("sticker_clear_button", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
const promptMessageID = 555
|
||||
|
||||
// seedPendingDelete stores a confirm action as /delpack would have.
|
||||
func seedPendingDelete(t *testing.T, s *state, mutate func(*PendingDelete)) PendingDelete {
|
||||
t.Helper()
|
||||
action := PendingDelete{
|
||||
ID: "abc123",
|
||||
OwnerID: testUser,
|
||||
Slug: "mypack",
|
||||
SetName: testSet,
|
||||
ChatID: testChat,
|
||||
MessageID: promptMessageID,
|
||||
CreatedAt: fixedNow.UnixMilli(),
|
||||
ExpiresAt: fixedNow.Add(pendingDeleteTTL).UnixMilli(),
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&action)
|
||||
}
|
||||
if err := s.pending.Put(context.Background(), pendingDeleteKey(action.OwnerID), action); err != nil {
|
||||
t.Fatalf("seed pending delete: %v", err)
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
// confirmPress builds the callback update for pressing the confirm button.
|
||||
func confirmPress(action PendingDelete, presser int64) *models.Update {
|
||||
return &models.Update{CallbackQuery: &models.CallbackQuery{
|
||||
ID: "cbq-1",
|
||||
From: models.User{ID: presser},
|
||||
Data: deleteCallbackData(action.ID),
|
||||
Message: models.MaybeInaccessibleMessage{
|
||||
Message: &models.Message{
|
||||
ID: action.MessageID,
|
||||
Chat: models.Chat{ID: action.ChatID},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// The prompt is the last point at which a user changing their pack's URL learns
|
||||
// the stickers do not survive it, so it must state all four consequences.
|
||||
func TestDelPack_PromptStatesConsequences(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 47)
|
||||
|
||||
if err := s.handleDelPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil {
|
||||
t.Fatalf("handleDelPack: %v", err)
|
||||
}
|
||||
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{"My Pack", "47", shareLink(testSet), "permanent"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("confirm prompt %q missing %q", text, want)
|
||||
}
|
||||
}
|
||||
if countMethod(rb, "deleteStickerSet") != 0 {
|
||||
t.Error("/delpack deleted without confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelPack_CallbackDataFitsTelegramLimit(t *testing.T) {
|
||||
id, err := newActionID()
|
||||
if err != nil {
|
||||
t.Fatalf("newActionID: %v", err)
|
||||
}
|
||||
data := deleteCallbackData(id)
|
||||
if len(data) > maxCallbackBytes {
|
||||
t.Errorf("callback data is %d bytes, over the %d-byte limit", len(data), maxCallbackBytes)
|
||||
}
|
||||
got, ok := parseDeleteCallback(data)
|
||||
if !ok || got != id {
|
||||
t.Errorf("parseDeleteCallback(%q) = (%q, %v), want (%q, true)", data, got, ok, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelPackCallback_HappyPath(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
action := seedPendingDelete(t, s, nil)
|
||||
|
||||
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
if countMethod(rb, "deleteStickerSet") != 1 {
|
||||
t.Fatalf("methods = %v, want one deleteStickerSet", methodsSent(rb))
|
||||
}
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("pack record survived a confirmed delete")
|
||||
}
|
||||
}
|
||||
|
||||
// Identity comes from From.ID, never from the payload — the payload is
|
||||
// client-controlled.
|
||||
// A foreign presser gets nothing. The mechanism is the key, not the owner
|
||||
// comparison: the action is loaded by the presser's own id, so someone else's
|
||||
// press finds no action at all and returns before the owner check is reached.
|
||||
//
|
||||
// Named for that, because the previous name claimed to exercise the owner
|
||||
// comparison at delpack_callback.go and did not — that branch is unreachable
|
||||
// by construction, and is kept only as defence in depth.
|
||||
func TestDelPackCallback_ForeignPresserResolvesToNothing(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
action := seedPendingDelete(t, s, nil)
|
||||
|
||||
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser+1)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
if countMethod(rb, "deleteStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want no delete", methodsSent(rb))
|
||||
}
|
||||
if _, found := loadPack(t, s); !found {
|
||||
t.Error("another user's press deleted the pack record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelPackCallback_RejectsExpired(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
action := seedPendingDelete(t, s, func(a *PendingDelete) {
|
||||
a.ExpiresAt = fixedNow.Add(-time.Second).UnixMilli()
|
||||
})
|
||||
|
||||
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
if countMethod(rb, "deleteStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want no delete after expiry", methodsSent(rb))
|
||||
}
|
||||
if _, found := loadPack(t, s); !found {
|
||||
t.Error("an expired press deleted the pack record")
|
||||
}
|
||||
}
|
||||
|
||||
// The action is bound to the message carrying the button, so a press arriving
|
||||
// from anywhere else resolves to nothing.
|
||||
func TestDelPackCallback_RejectsWrongBinding(t *testing.T) {
|
||||
cases := map[string]func(*models.Update){
|
||||
"different chat": func(u *models.Update) { u.CallbackQuery.Message.Message.Chat.ID = testChat + 1 },
|
||||
"different message": func(u *models.Update) { u.CallbackQuery.Message.Message.ID = promptMessageID + 1 },
|
||||
// MaybeInaccessibleMessage is nil for messages Telegram marks
|
||||
// inaccessible; the panic barrier is a backstop, not a substitute.
|
||||
"inaccessible message": func(u *models.Update) { u.CallbackQuery.Message.Message = nil },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
action := seedPendingDelete(t, s, nil)
|
||||
|
||||
upd := confirmPress(action, testUser)
|
||||
mutate(upd)
|
||||
|
||||
if err := s.handleDelPackCallback(context.Background(), rb.Bot, upd); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
if countMethod(rb, "deleteStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want no delete", methodsSent(rb))
|
||||
}
|
||||
if _, found := loadPack(t, s); !found {
|
||||
t.Error("pack record deleted despite a broken binding")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Single use: the action is consumed before the destructive call, so a second
|
||||
// press finds nothing.
|
||||
func TestDelPackCallback_SecondPressIsInert(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
action := seedPendingDelete(t, s, nil)
|
||||
|
||||
press := func() {
|
||||
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
}
|
||||
press()
|
||||
press()
|
||||
|
||||
if got := countMethod(rb, "deleteStickerSet"); got != 1 {
|
||||
t.Errorf("deleteStickerSet calls = %d, want exactly 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A set Telegram already lost still clears the record — that is what unblocks
|
||||
// /newpack after the phantom-record failure mode.
|
||||
func TestDelPackCallback_MissingSetStillClearsRecord(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
rb.FailMethodCode("deleteStickerSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
action := seedPendingDelete(t, s, nil)
|
||||
|
||||
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("record survived; /newpack stays blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror image: an unclassifiable failure leaves the pack alone.
|
||||
func TestDelPackCallback_TransientErrorKeepsRecord(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
rb.FailMethod("deleteStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
action := seedPendingDelete(t, s, nil)
|
||||
|
||||
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
if _, found := loadPack(t, s); !found {
|
||||
t.Error("a transient delete failure destroyed the pack record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelPackCallback_IgnoresNonCallbackUpdate(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
if err := s.handleDelPackCallback(context.Background(), rb.Bot, &models.Update{}); err != nil {
|
||||
t.Fatalf("callback with no query: %v", err)
|
||||
}
|
||||
if len(rb.Sent()) != 0 {
|
||||
t.Errorf("methods = %v, want none", methodsSent(rb))
|
||||
}
|
||||
}
|
||||
|
||||
// The stale-confirmation regression. /delpack is the documented route to a new
|
||||
// pack URL (delete, then /newpack under a new name), so a user genuinely can
|
||||
// have an old prompt in scrollback while holding a *different*, live pack.
|
||||
//
|
||||
// Pressing the stale button deleted the old set, got STICKERSET_INVALID, and
|
||||
// then cleared the record by owner id — erasing the record of the new, live
|
||||
// pack and orphaning it permanently.
|
||||
func TestDelPackCallback_StalePressLeavesTheCurrentPackAlone(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
// Registered so that if the guard ever lets the call through, it is the
|
||||
// assertion below that reports it rather than a confusing downstream error.
|
||||
rb.FailMethodCode("deleteStickerSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
// A confirmation written for the *old* pack.
|
||||
stale := seedPendingDelete(t, s, func(a *PendingDelete) {
|
||||
a.Slug = "oldslug"
|
||||
a.SetName = "oldslug_by_testbot"
|
||||
})
|
||||
|
||||
// The user has since created a new pack.
|
||||
current := Pack{Slug: "newslug", Name: "newslug_by_testbot", Title: "New", OwnerID: testUser, Count: 7}
|
||||
if err := s.store.Put(ctx, packKey(testUser), current); err != nil {
|
||||
t.Fatalf("seed current pack: %v", err)
|
||||
}
|
||||
|
||||
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(stale, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
|
||||
// Nothing may reach Telegram. The old name may since have been claimed by
|
||||
// another user, and DeleteStickerSet is keyed by name alone — asserting only
|
||||
// that this user's own record survived misses the cross-user damage
|
||||
// entirely, which is how this went unnoticed.
|
||||
if n := countMethod(rb, "deleteStickerSet"); n != 0 {
|
||||
t.Errorf("deleteStickerSet calls = %d, want 0 — a stale confirmation reached Telegram", n)
|
||||
}
|
||||
|
||||
pack, found := loadPack(t, s)
|
||||
if !found {
|
||||
t.Fatal("the live pack's record was deleted by a stale confirmation")
|
||||
}
|
||||
if pack.Name != current.Name || pack.Count != 7 {
|
||||
t.Errorf("pack = %+v, want the live newslug record untouched", pack)
|
||||
}
|
||||
}
|
||||
|
||||
// Two /delpack runs must not leave two live capabilities. The second prompt
|
||||
// supersedes the first, and pressing the first afterwards does nothing.
|
||||
func TestDelPack_SecondPromptSupersedesTheFirst(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
ctx := context.Background()
|
||||
|
||||
run := func() PendingDelete {
|
||||
if err := s.handleDelPack(ctx, rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil {
|
||||
t.Fatalf("handleDelPack: %v", err)
|
||||
}
|
||||
action, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser))
|
||||
if err != nil {
|
||||
t.Fatalf("load action: %v", err)
|
||||
}
|
||||
return action
|
||||
}
|
||||
first := run()
|
||||
second := run()
|
||||
|
||||
if first.ID == second.ID {
|
||||
t.Fatal("both prompts share an id; the test cannot distinguish them")
|
||||
}
|
||||
// Exactly one action is stored, not two.
|
||||
keys, err := s.pending.List(ctx, pendingDeletePrefix)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Errorf("stored pending actions = %d, want 1 — a public command must not accumulate documents", len(keys))
|
||||
}
|
||||
|
||||
// The superseded button is inert.
|
||||
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(first, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
if countMethod(rb, "deleteStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want the stale button to delete nothing", methodsSent(rb))
|
||||
}
|
||||
if _, found := loadPack(t, s); !found {
|
||||
t.Error("the superseded button deleted the pack")
|
||||
}
|
||||
}
|
||||
|
||||
// Anyone in a group can tap anyone's inline button. Checking supersession
|
||||
// before the chat/message binding meant a bystander's press stripped the button
|
||||
// off a live confirmation they had no part in — no data leak, but the victim's
|
||||
// prompt was destroyed and they had to start over.
|
||||
func TestDelPackCallback_BystanderCannotTouchAnotherUsersPrompt(t *testing.T) {
|
||||
const bystander = int64(2)
|
||||
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
ctx := context.Background()
|
||||
|
||||
victimAction := seedPendingDelete(t, s, nil)
|
||||
|
||||
// The bystander has their own pending confirmation, bound to their own
|
||||
// message — this is what made action.ID differ and triggered the clear.
|
||||
bystanderAction := PendingDelete{
|
||||
ID: "bbbb2222", OwnerID: bystander, Slug: "theirs", SetName: "theirs_by_testbot",
|
||||
ChatID: testChat, MessageID: 777,
|
||||
CreatedAt: fixedNow.UnixMilli(), ExpiresAt: fixedNow.Add(pendingDeleteTTL).UnixMilli(),
|
||||
}
|
||||
if err := s.pending.Put(ctx, pendingDeleteKey(bystander), bystanderAction); err != nil {
|
||||
t.Fatalf("seed bystander action: %v", err)
|
||||
}
|
||||
|
||||
// The bystander presses the victim's button.
|
||||
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(victimAction, bystander)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
|
||||
if countMethod(rb, "editMessageReplyMarkup") != 0 {
|
||||
t.Errorf("methods = %v; a bystander cleared the button on someone else's prompt", methodsSent(rb))
|
||||
}
|
||||
if countMethod(rb, "deleteStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want no delete", methodsSent(rb))
|
||||
}
|
||||
// The victim's confirmation is untouched and still usable.
|
||||
if _, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)); err != nil {
|
||||
t.Errorf("victim's pending action was consumed by a bystander's press: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A deleted pack must give its name back. Holding it forever would shrink the
|
||||
// global namespace permanently and let a /newpack + /delpack loop burn one name
|
||||
// per cycle.
|
||||
func TestDelPackCallback_ReleasesTheName(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
ctx := context.Background()
|
||||
action := seedPendingDelete(t, s, nil)
|
||||
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); !held {
|
||||
t.Fatal("fixture is wrong: the pack should start with its name reserved")
|
||||
}
|
||||
|
||||
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(action, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
|
||||
t.Error("the name is still reserved after the pack was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// A confirmation must never outlive the authority it was issued under.
|
||||
//
|
||||
// Each case leaves the PendingDelete intact and breaks the pack record a
|
||||
// different way, so the press actually reaches the under-lock allowlist. An
|
||||
// earlier version of this test called dropPackRecord, which now clears the
|
||||
// confirmation too — so the callback returned at the pending.Get miss ~50 lines
|
||||
// before the guard, and the test passed with the whole guard reverted. Every
|
||||
// disjunct is exercised here on purpose.
|
||||
//
|
||||
// The damage is cross-user: DeleteStickerSet is keyed by set name, which
|
||||
// Telegram authorises for every set this bot created, so a press with stale
|
||||
// authority destroys whoever holds that name at press time.
|
||||
func TestDelPackCallback_StaleAuthorityNeverReachesTelegram(t *testing.T) {
|
||||
const victim = int64(99)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
break_ func(t *testing.T, s *state, ctx context.Context)
|
||||
}{
|
||||
{
|
||||
// !found — a self-heal removed the record but the prompt survived.
|
||||
name: "record gone",
|
||||
break_: func(t *testing.T, s *state, ctx context.Context) {
|
||||
if err := s.store.Delete(ctx, packKey(testUser)); err != nil {
|
||||
t.Fatalf("delete record: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// current.Pending — the record is an unconfirmed attempt, which is
|
||||
// no evidence this bot made that set for this user.
|
||||
name: "record is unconfirmed",
|
||||
break_: func(t *testing.T, s *state, ctx context.Context) {
|
||||
pack, _ := loadPack(t, s)
|
||||
pack.Pending = true
|
||||
if err := s.store.Put(ctx, packKey(testUser), pack); err != nil {
|
||||
t.Fatalf("mark pending: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// !ownsSet — the record has moved on to a different pack.
|
||||
name: "record moved on",
|
||||
break_: func(t *testing.T, s *state, ctx context.Context) {
|
||||
if err := s.store.Put(ctx, packKey(testUser), Pack{
|
||||
Slug: "newslug", Name: "newslug_by_testbot", Title: "New", OwnerID: testUser, Count: 7,
|
||||
}); err != nil {
|
||||
t.Fatalf("move record: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
seedPack(t, s, 3)
|
||||
action := seedPendingDelete(t, s, nil)
|
||||
tc.break_(t, s, ctx)
|
||||
|
||||
// Someone else now holds that name, with a live set behind it.
|
||||
if err := s.store.Put(ctx, packKey(victim), Pack{
|
||||
Slug: "mypack", Name: testSet, Title: "V's pack", OwnerID: victim, Count: 5,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed victim: %v", err)
|
||||
}
|
||||
|
||||
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(action, testUser)); err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
|
||||
if n := countMethod(rb, "deleteStickerSet"); n != 0 {
|
||||
t.Errorf("deleteStickerSet calls = %d, want 0 — a stale confirmation destroyed the current holder's pack", n)
|
||||
}
|
||||
if _, _, err := s.store.Get(ctx, packKey(victim)); err != nil {
|
||||
t.Errorf("victim's pack record damaged: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxSourceBytes bounds what this module will pull over the network.
|
||||
// Telegram-compressed photo sizes are typically well under 500 KB.
|
||||
maxSourceBytes = 2 << 20
|
||||
|
||||
// downloadTimeout is this module's own ceiling. The library's shared HTTP
|
||||
// client allows 60s, which is far too long to inherit on a dispatcher where
|
||||
// one slow handler stalls every other user.
|
||||
downloadTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
// errDownloadFailed replaces every error from the download path.
|
||||
//
|
||||
// This is a security boundary, not tidiness. FileDownloadLink returns
|
||||
// "https://api.telegram.org/file/bot<TOKEN>/<path>", and every transport
|
||||
// failure from http.Client.Do is a *url.Error whose Error() embeds the full
|
||||
// URL — which the dispatcher then logs verbatim. A mid-transfer timeout, which
|
||||
// is trivially reachable, would print the bot token to stdout and every log
|
||||
// shipper downstream.
|
||||
var errDownloadFailed = errors.New("sticker: download failed")
|
||||
|
||||
// downloadClient is separate from the library's so its timeout is ours.
|
||||
var downloadClient = &http.Client{Timeout: downloadTimeout}
|
||||
|
||||
// downloadFile fetches a Telegram file by ID, bounded in bytes and time.
|
||||
//
|
||||
// The original error is discarded rather than wrapped: wrapping would keep the
|
||||
// URL reachable through errors.Unwrap and %v, which defeats the point.
|
||||
func downloadFile(ctx context.Context, b *bot.Bot, fileID string) ([]byte, error) {
|
||||
f, err := b.GetFile(ctx, &bot.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
log.Error("sticker_getfile", "file_id", fileID, "reason", classify(err))
|
||||
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
|
||||
}
|
||||
if f.FileSize > maxSourceBytes {
|
||||
return nil, refuse(fmt.Sprintf("That image is too large — keep it under %d MB.", maxSourceBytes>>20))
|
||||
}
|
||||
|
||||
link := b.FileDownloadLink(f)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
|
||||
if err != nil {
|
||||
log.Error("sticker_download_request", "file_id", fileID, "reason", classify(err))
|
||||
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
|
||||
}
|
||||
resp, err := downloadClient.Do(req)
|
||||
if err != nil {
|
||||
log.Error("sticker_download", "file_id", fileID, "reason", classify(err))
|
||||
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Error("sticker_download", "file_id", fileID, "reason", "status", "status", resp.StatusCode)
|
||||
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
|
||||
}
|
||||
|
||||
// Never trust Content-Length; bound the reader itself. One extra byte is
|
||||
// read so an oversized body is detected rather than silently truncated.
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSourceBytes+1))
|
||||
if err != nil {
|
||||
log.Error("sticker_download_read", "file_id", fileID, "reason", classify(err))
|
||||
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
|
||||
}
|
||||
if len(data) > maxSourceBytes {
|
||||
return nil, refuse(fmt.Sprintf("That image is too large — keep it under %d MB.", maxSourceBytes>>20))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// classify reduces an error to a coarse label that cannot contain a URL.
|
||||
//
|
||||
// It deliberately inspects only the error's *type*, never its text: any path
|
||||
// that formats the original error risks carrying the token along with it.
|
||||
func classify(err error) string {
|
||||
var urlErr *url.Error
|
||||
switch {
|
||||
case err == nil:
|
||||
return "none"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "cancelled"
|
||||
case errors.As(err, &urlErr):
|
||||
if urlErr.Timeout() {
|
||||
return "timeout"
|
||||
}
|
||||
return "transport"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
)
|
||||
|
||||
// The download URL is "https://api.telegram.org/file/bot<TOKEN>/<path>", and
|
||||
// every transport failure from http.Client.Do is a *url.Error whose Error()
|
||||
// embeds it in full. The dispatcher logs a handler's returned error verbatim,
|
||||
// so an error that carried the URL would print the bot token to stdout and
|
||||
// every log shipper downstream.
|
||||
//
|
||||
// This asserts the property directly rather than trusting the discipline: a
|
||||
// forced transport failure must produce an error that contains neither the
|
||||
// token, nor "bot", nor any part of the URL.
|
||||
func TestDownloadFile_ErrorNeverLeaksTokenOrURL(t *testing.T) {
|
||||
const token = "123456:SUPER-SECRET-BOT-TOKEN"
|
||||
|
||||
// A server that accepts getFile, then hangs up mid-download.
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/getFile") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/file_1.jpg"}}`))
|
||||
return
|
||||
}
|
||||
// The file fetch: close the connection without a response.
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
srv.CloseClientConnections()
|
||||
return
|
||||
}
|
||||
conn, _, err := hj.Hijack()
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
b, err := bot.New(token, bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("bot.New: %v", err)
|
||||
}
|
||||
|
||||
_, err = downloadFile(context.Background(), b, "f1")
|
||||
if err == nil {
|
||||
t.Fatal("downloadFile succeeded against a hung-up server; want an error")
|
||||
}
|
||||
if !errors.Is(err, errDownloadFailed) {
|
||||
t.Errorf("err = %v, want it to be errDownloadFailed", err)
|
||||
}
|
||||
|
||||
text := err.Error()
|
||||
for _, forbidden := range []string{token, "SUPER-SECRET", "bot", "http", srv.URL} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Errorf("error text %q contains %q — the token or URL can reach the logs", text, forbidden)
|
||||
}
|
||||
}
|
||||
// Unwrapping must not reach the original either: %v on a wrapped *url.Error
|
||||
// would put the URL back.
|
||||
if inner := errors.Unwrap(err); inner != nil && strings.Contains(inner.Error(), token) {
|
||||
t.Errorf("unwrapped error %q still carries the token", inner)
|
||||
}
|
||||
}
|
||||
|
||||
// classify must reduce an error to a fixed label. It inspects only the error's
|
||||
// type, never its text, so there is no path by which a URL can ride along.
|
||||
func TestClassify_ReturnsFixedLabels(t *testing.T) {
|
||||
allowed := map[string]bool{"none": true, "timeout": true, "cancelled": true, "transport": true, "unknown": true}
|
||||
cases := []error{
|
||||
nil,
|
||||
context.DeadlineExceeded,
|
||||
context.Canceled,
|
||||
errors.New("https://api.telegram.org/file/bot123:SECRET/x.jpg refused"),
|
||||
}
|
||||
for _, err := range cases {
|
||||
got := classify(err)
|
||||
if !allowed[got] {
|
||||
t.Errorf("classify(%v) = %q, which is not one of the fixed labels", err, got)
|
||||
}
|
||||
if strings.Contains(got, "SECRET") || strings.Contains(got, "http") {
|
||||
t.Errorf("classify leaked error text: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The size guard runs on the metadata getFile returns, so an oversized file
|
||||
// costs zero bytes of transfer.
|
||||
func TestDownloadFile_RejectsOversizedBeforeFetching(t *testing.T) {
|
||||
var fetches int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/getFile") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":99999999,"file_path":"photos/huge.jpg"}}`))
|
||||
return
|
||||
}
|
||||
fetches++
|
||||
_, _ = w.Write([]byte("should never be fetched"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("bot.New: %v", err)
|
||||
}
|
||||
|
||||
if _, err := downloadFile(context.Background(), b, "f1"); err == nil {
|
||||
t.Fatal("downloadFile accepted an oversized file")
|
||||
}
|
||||
if fetches != 0 {
|
||||
t.Errorf("made %d HTTP fetches for an oversized file, want 0", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Length is attacker-controlled; the reader itself is what bounds the
|
||||
// transfer.
|
||||
func TestDownloadFile_BoundsBodyRegardlessOfContentLength(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/getFile") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/lie.jpg"}}`))
|
||||
return
|
||||
}
|
||||
// Claims to be tiny, sends far more.
|
||||
w.Header().Set("Content-Length", "10")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
chunk := make([]byte, 64<<10)
|
||||
for written := 0; written < maxSourceBytes+(128<<10); written += len(chunk) {
|
||||
if _, err := w.Write(chunk); err != nil {
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("bot.New: %v", err)
|
||||
}
|
||||
|
||||
data, err := downloadFile(context.Background(), b, "f1")
|
||||
if err == nil {
|
||||
t.Fatalf("downloadFile accepted %d bytes despite the %d-byte cap", len(data), maxSourceBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultEmoji is used when a sticker is added with no emoji given and the
|
||||
// source carries none. Telegram requires at least one.
|
||||
defaultEmoji = "⭐"
|
||||
// maxEmojiPerSticker mirrors the documented emoji_list range of 1-20.
|
||||
maxEmojiPerSticker = 20
|
||||
)
|
||||
|
||||
// Code points that bind to the cluster before them rather than starting one.
|
||||
// Written as hex because the literals are invisible in source.
|
||||
const (
|
||||
zwj = rune(0x200D) // zero-width joiner: 👩👩👧 is one emoji
|
||||
variationSelector16 = rune(0xFE0F) // forces emoji presentation
|
||||
variationSelector15 = rune(0xFE0E) // forces text presentation
|
||||
keycapCombining = rune(0x20E3) // the enclosing box of 1️⃣
|
||||
tagLow = rune(0xE0020) // tag block: 🏴 + tags spells a subdivision flag
|
||||
tagHigh = rune(0xE007F)
|
||||
)
|
||||
|
||||
// parseEmoji splits an emoji argument run into individual emoji, accepting both
|
||||
// "😂 🔥" and "😂🔥".
|
||||
//
|
||||
// Go has no stdlib grapheme segmentation, and a dependency for this one job is
|
||||
// disproportionate — so this hand-rolls the subset of UAX #29 that emoji need:
|
||||
// ZWJ sequences, variation selectors, skin-tone modifiers, regional-indicator
|
||||
// pairs, and keycaps. A sequence that splits wrongly is a test case to add, not
|
||||
// a redesign.
|
||||
//
|
||||
// Because no sticker command takes a pack argument any more, every argument
|
||||
// reaching here is meant to be an emoji: a stray word fails loudly rather than
|
||||
// being silently reinterpreted.
|
||||
func parseEmoji(args []string) ([]string, error) {
|
||||
joined := strings.Join(args, "")
|
||||
joined = strings.TrimSpace(joined)
|
||||
if joined == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, cluster := range splitClusters(joined) {
|
||||
if isSpace(cluster) {
|
||||
continue
|
||||
}
|
||||
if !isEmojiCluster(cluster) {
|
||||
return nil, refuse(fmt.Sprintf("%q is not an emoji. Give one or more emoji, like 😂🔥.", cluster))
|
||||
}
|
||||
out = append(out, cluster)
|
||||
}
|
||||
if len(out) > maxEmojiPerSticker {
|
||||
return nil, refuse(fmt.Sprintf("At most %d emoji per sticker.", maxEmojiPerSticker))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// splitClusters breaks s into emoji-aware clusters.
|
||||
func splitClusters(s string) []string {
|
||||
runes := []rune(s)
|
||||
var out []string
|
||||
for i := 0; i < len(runes); {
|
||||
start := i
|
||||
i++
|
||||
// A regional indicator pairs with a following one to form a flag.
|
||||
if isRegionalIndicator(runes[start]) && i < len(runes) && isRegionalIndicator(runes[i]) {
|
||||
i++
|
||||
out = append(out, string(runes[start:i]))
|
||||
continue
|
||||
}
|
||||
// Absorb everything that binds leftward: modifiers, variation
|
||||
// selectors, combining marks, keycaps, and ZWJ-joined continuations.
|
||||
for i < len(runes) {
|
||||
r := runes[i]
|
||||
switch {
|
||||
case r == zwj:
|
||||
// Absorb the joiner and its continuation only when what follows
|
||||
// can actually continue an emoji sequence. Taking the next rune
|
||||
// unconditionally let "😀<ZWJ>🇻🇳" swallow the flag's first
|
||||
// half and emit the orphaned second half as its own cluster —
|
||||
// two invalid emoji, both of which passed validation.
|
||||
if i+1 < len(runes) && isEmojiRune(runes[i+1]) && !isRegionalIndicator(runes[i+1]) {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// A dangling joiner. Absorb it so it cannot start a cluster of
|
||||
// its own; trimJoiners drops it from the emitted cluster.
|
||||
i++
|
||||
goto done
|
||||
case isBinding(r):
|
||||
i++
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
// A cluster ending in a joiner is incomplete. "😀<ZWJ>" is not an emoji
|
||||
// and Telegram rejects it, but it used to pass validation because the
|
||||
// cluster's first rune looked fine.
|
||||
if cluster := trimJoiners(string(runes[start:i])); cluster != "" {
|
||||
out = append(out, cluster)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trimJoiners strips leading and trailing zero-width joiners from a cluster.
|
||||
func trimJoiners(cluster string) string {
|
||||
return strings.Trim(cluster, string(zwj))
|
||||
}
|
||||
|
||||
// isBinding reports whether r attaches to the preceding cluster.
|
||||
func isBinding(r rune) bool {
|
||||
switch {
|
||||
case r == variationSelector16, r == variationSelector15, r == keycapCombining:
|
||||
return true
|
||||
case r >= tagLow && r <= tagHigh:
|
||||
// Subdivision flags (🏴 + "gbeng" + terminator) are one emoji. Without
|
||||
// this the base flag split from its tags and the tags, being invisible,
|
||||
// produced a refusal quoting characters the user could not see.
|
||||
return true
|
||||
case isSkinTone(r):
|
||||
return true
|
||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Me, r):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSkinTone(r rune) bool { return r >= 0x1F3FB && r <= 0x1F3FF }
|
||||
func isRegionalIndicator(r rune) bool { return r >= 0x1F1E6 && r <= 0x1F1FF }
|
||||
|
||||
func isSpace(cluster string) bool { return strings.TrimSpace(cluster) == "" }
|
||||
|
||||
// isEmojiCluster reports whether a cluster is emoji rather than ordinary text.
|
||||
//
|
||||
// The test is on the cluster's first rune, since that is what determines the
|
||||
// cluster's identity — the rest is bound modifiers. Keycaps are the exception:
|
||||
// "1️⃣" starts with an ASCII digit, so a cluster carrying the keycap combining
|
||||
// mark counts regardless of its base.
|
||||
func isEmojiCluster(cluster string) bool {
|
||||
runes := []rune(cluster)
|
||||
if len(runes) == 0 {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsRune(cluster, keycapCombining) {
|
||||
return true
|
||||
}
|
||||
if isRegionalIndicator(runes[0]) {
|
||||
// A flag is exactly two regional indicators. An odd count leaves a lone
|
||||
// one at the end, which is not an emoji — Telegram rejects it, and it
|
||||
// used to pass because isEmojiRune accepts the block.
|
||||
return len(runes) == 2 && isRegionalIndicator(runes[1])
|
||||
}
|
||||
return isEmojiRune(runes[0])
|
||||
}
|
||||
|
||||
// emojiRanges are the Unicode blocks Telegram's emoji actually come from.
|
||||
// Deliberately ranges rather than a property lookup: Go's unicode package
|
||||
// exposes no Emoji property, and the blocks are stable.
|
||||
var emojiRanges = [...]struct{ lo, hi rune }{
|
||||
{0x1F300, 0x1FAFF}, // pictographs, emoticons, transport, symbols, extended-A
|
||||
{0x1F000, 0x1F2FF}, // mahjong, dominoes, playing cards, enclosed
|
||||
{0x2600, 0x27BF}, // misc symbols + dingbats
|
||||
{0x2B00, 0x2BFF}, // arrows and stars (⭐ lives here)
|
||||
{0x2190, 0x21FF}, // arrows
|
||||
{0x2300, 0x23FF}, // misc technical (⌚, ⏰)
|
||||
{0x25A0, 0x25FF}, // geometric shapes
|
||||
{0x1F1E6, 0x1F1FF}, // regional indicators; isEmojiCluster requires a pair
|
||||
}
|
||||
|
||||
// emojiSingletons are emoji stranded between the blocks above.
|
||||
//
|
||||
// Listed one by one rather than by widening a range, because their neighbours
|
||||
// are not emoji: Ⓜ sits in enclosed alphanumerics next to circled digits, and ©
|
||||
// and ® sit in Latin-1 next to ordinary punctuation. Widening to cover them
|
||||
// would start accepting text as emoji, which Telegram then rejects.
|
||||
var emojiSingletons = map[rune]bool{
|
||||
0x203C: true, // ‼
|
||||
0x2049: true, // ⁉
|
||||
0x2122: true, // ™
|
||||
0x2139: true, // ℹ
|
||||
0x00A9: true, // ©
|
||||
0x00AE: true, // ®
|
||||
0x2934: true, // ⤴
|
||||
0x2935: true, // ⤵
|
||||
0x24C2: true, // Ⓜ
|
||||
0x3030: true, // 〰
|
||||
0x303D: true, // 〽
|
||||
0x3297: true, // ㊗
|
||||
0x3299: true, // ㊙
|
||||
}
|
||||
|
||||
func isEmojiRune(r rune) bool {
|
||||
if emojiSingletons[r] {
|
||||
return true
|
||||
}
|
||||
for _, block := range emojiRanges {
|
||||
if r >= block.lo && r <= block.hi {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEmoji(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"single", []string{"😂"}, []string{"😂"}},
|
||||
{"space separated", []string{"😂", "🔥"}, []string{"😂", "🔥"}},
|
||||
{"joined in one arg", []string{"😂🔥"}, []string{"😂", "🔥"}},
|
||||
{"zwj family stays one", []string{"👩\u200d👩\u200d👧"}, []string{"👩\u200d👩\u200d👧"}},
|
||||
{"skin tone binds", []string{"👍🏽"}, []string{"👍🏽"}},
|
||||
{"flag is one cluster", []string{"🇻🇳"}, []string{"🇻🇳"}},
|
||||
{"two flags", []string{"🇻🇳🇯🇵"}, []string{"🇻🇳", "🇯🇵"}},
|
||||
{"keycap stays one", []string{"1️⃣"}, []string{"1️⃣"}},
|
||||
{"variation selector binds", []string{"❤️"}, []string{"❤️"}},
|
||||
{"star default", []string{"⭐"}, []string{"⭐"}},
|
||||
{"empty", nil, nil},
|
||||
{"blank", []string{" "}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseEmoji(tc.args)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmoji(%q) error: %v", tc.args, err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("parseEmoji(%q) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("cluster %d = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A stray word must fail loudly. With no pack argument left on /addsticker,
|
||||
// there is nothing else an argument could have meant.
|
||||
func TestParseEmoji_RejectsPlainText(t *testing.T) {
|
||||
for _, arg := range []string{"mypack", "hello 😂", "a"} {
|
||||
if got, err := parseEmoji([]string{arg}); err == nil {
|
||||
t.Errorf("parseEmoji(%q) = %q, want an error", arg, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmoji_CapsAtTwenty(t *testing.T) {
|
||||
twenty := strings.Repeat("😂", maxEmojiPerSticker)
|
||||
if got, err := parseEmoji([]string{twenty}); err != nil {
|
||||
t.Fatalf("parseEmoji(20 emoji) error: %v (got %d)", err, len(got))
|
||||
}
|
||||
if _, err := parseEmoji([]string{twenty + "🔥"}); err == nil {
|
||||
t.Error("parseEmoji(21 emoji) succeeded, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// Refusals are shown to the user verbatim, so they must be userError.
|
||||
func TestParseEmoji_RefusalIsUserFacing(t *testing.T) {
|
||||
_, err := parseEmoji([]string{"notanemoji"})
|
||||
if err == nil {
|
||||
t.Fatal("want an error")
|
||||
}
|
||||
if _, ok := err.(userError); !ok {
|
||||
t.Errorf("err is %T, want userError so the handler can echo it", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseEmoji_ClusterEdgeCases pins the emoji-clustering rules that a
|
||||
// hand-rolled segmenter gets wrong. Every case here failed before the
|
||||
// clustering fix: the first group was refused outright, the second silently
|
||||
// produced an emoji_list Telegram rejects.
|
||||
func TestParseEmoji_ClusterEdgeCases(t *testing.T) {
|
||||
accepted := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
// Emoji that fall between the blocks the range table covers.
|
||||
{"copyright", "©️", []string{"©️"}},
|
||||
{"registered", "®️", []string{"®️"}},
|
||||
{"wavy dash", "〰️", []string{"〰️"}},
|
||||
{"part alternation", "〽️", []string{"〽️"}},
|
||||
{"japanese congratulations", "㊗️", []string{"㊗️"}},
|
||||
{"japanese secret", "㊙️", []string{"㊙️"}},
|
||||
{"circled m", "Ⓜ️", []string{"Ⓜ️"}},
|
||||
{"arrow curving up", "⤴️", []string{"⤴️"}},
|
||||
{"arrow curving down", "⤵️", []string{"⤵️"}},
|
||||
|
||||
// A subdivision flag is a base flag plus invisible tag characters.
|
||||
{
|
||||
"tag sequence flag",
|
||||
"\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F",
|
||||
[]string{"\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F"},
|
||||
},
|
||||
|
||||
// A dangling joiner is dropped rather than shipped.
|
||||
{"trailing joiner", "\U0001F600\u200d", []string{"\U0001F600"}},
|
||||
|
||||
// The joiner must not swallow a flag's first half.
|
||||
{
|
||||
"joiner before a flag",
|
||||
"\U0001F600\u200d\U0001F1FB\U0001F1F3",
|
||||
[]string{"\U0001F600", "\U0001F1FB\U0001F1F3"},
|
||||
},
|
||||
|
||||
// Sequences that already worked, kept here so a fix cannot regress them.
|
||||
{"family", "\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466", []string{"\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466"}},
|
||||
{"skin tone", "\U0001F44D\U0001F3FD", []string{"\U0001F44D\U0001F3FD"}},
|
||||
{"keycap", "1️⃣", []string{"1️⃣"}},
|
||||
{"flag", "\U0001F1FB\U0001F1F3", []string{"\U0001F1FB\U0001F1F3"}},
|
||||
}
|
||||
for _, tc := range accepted {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseEmoji([]string{tc.in})
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmoji(%+q) refused: %v", tc.in, err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("parseEmoji(%+q) = %+q, want %+q", tc.in, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("parseEmoji(%+q) = %+q, want %+q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
refused := []struct {
|
||||
name string
|
||||
in string
|
||||
}{
|
||||
// A lone regional indicator is half a flag, and Telegram rejects it.
|
||||
{"lone regional indicator", "\U0001F1FB"},
|
||||
{"odd regional indicator count", "\U0001F1FB\U0001F1F3\U0001F1FA"},
|
||||
{"plain text", "hello"},
|
||||
}
|
||||
for _, tc := range refused {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseEmoji([]string{tc.in})
|
||||
if err == nil {
|
||||
t.Fatalf("parseEmoji(%+q) = %+q, want refusal", tc.in, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
)
|
||||
|
||||
// userError carries text meant to be shown to the user verbatim.
|
||||
//
|
||||
// The module has two kinds of failure and must never confuse them: a refusal
|
||||
// the user can act on ("that pack name is too long"), and an internal failure
|
||||
// that must not reach a reply at all — plan rule 5 exists because a transport
|
||||
// error's text can embed the bot token. Wrapping the first kind in a distinct
|
||||
// type makes "is this safe to echo?" a type question instead of a judgement
|
||||
// call at each call site.
|
||||
type userError struct{ msg string }
|
||||
|
||||
func (e userError) Error() string { return e.msg }
|
||||
|
||||
// refuse builds a userError. Its text is replied verbatim, so write it as a
|
||||
// sentence addressed to the user.
|
||||
func refuse(msg string) error { return userError{msg: msg} }
|
||||
|
||||
// errNoUsername means the bot's own username is unavailable, so no new set name
|
||||
// can be built. Internal, not user-facing: nothing the caller does fixes it.
|
||||
var errNoUsername = errors.New("sticker: bot has no username")
|
||||
|
||||
// isStickerSetMissing reports whether err positively says the set does not
|
||||
// exist on Telegram's side.
|
||||
//
|
||||
// Classification here is positive-only, and deliberately so: this is the one
|
||||
// signal that authorises deleting a user's pack record. A network blip, a 429,
|
||||
// or a context cancelled by SIGTERM must never be read as "the pack is gone" —
|
||||
// under one pack per user that would destroy the only record of a live pack and
|
||||
// block /newpack until the phantom cleared.
|
||||
func isStickerSetMissing(err error) bool {
|
||||
return errors.Is(err, bot.ErrorBadRequest) &&
|
||||
strings.Contains(err.Error(), "STICKERSET_INVALID")
|
||||
}
|
||||
|
||||
// apiRefusal maps a Telegram API error to user-facing text, or returns ok=false
|
||||
// when the error has no specific meaning and should be treated as a failure.
|
||||
//
|
||||
// Matching is on MTProto code substrings rather than prose. The Bot API server
|
||||
// rewrites only three of these into English (PACK_SHORT_NAME_OCCUPIED,
|
||||
// PACK_SHORT_NAME_INVALID, STICKER_EMOJI_INVALID); the rest arrive as
|
||||
// "Bad Request: <CODE>", and the prose for the three could change without
|
||||
// notice. Both forms are matched where they differ.
|
||||
func apiRefusal(err error) (string, bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
text := err.Error()
|
||||
switch {
|
||||
case contains(text, "PACK_SHORT_NAME_OCCUPIED", "already occupied"):
|
||||
return "That pack name is taken. Pick a different one.", true
|
||||
case contains(text, "PACK_SHORT_NAME_INVALID", "invalid sticker set name"):
|
||||
return "Telegram rejected that pack name. Use lowercase letters, digits and single underscores.", true
|
||||
case contains(text, "PACK_TITLE_INVALID"):
|
||||
return "Telegram rejected that title. Try a shorter, simpler one.", true
|
||||
case contains(text, "STICKERSET_INVALID"):
|
||||
return "Your pack no longer exists on Telegram. Use /newpack to create a new one.", true
|
||||
case contains(text, "STICKERS_TOO_MUCH"):
|
||||
return "Your pack is full (120 stickers).", true
|
||||
case contains(text, "STICKER_EMOJI_INVALID", "invalid sticker emojis"):
|
||||
return "Telegram rejected those emoji. Try different ones.", true
|
||||
case contains(text, "too many emoji specified"):
|
||||
return "At most 20 emoji per sticker.", true
|
||||
case contains(text, "STICKER_PNG_DIMENSIONS", "STICKER_DIMENSIONS_INVALID"):
|
||||
return "Telegram rejected that image's dimensions.", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// replyAPIError converts a Telegram API error into a reply. Errors with no
|
||||
// specific mapping are logged and answered generically — the raw error never
|
||||
// reaches the user.
|
||||
func replyAPIError(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error {
|
||||
if text, ok := apiRefusal(err); ok {
|
||||
return reply(ctx, b, msg, text)
|
||||
}
|
||||
log.Error(op, "err", err)
|
||||
return reply(ctx, b, msg, genericFailure)
|
||||
}
|
||||
|
||||
func contains(text string, needles ...string) bool {
|
||||
for _, n := range needles {
|
||||
if strings.Contains(text, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// createRefused reports whether err proves CreateNewStickerSet created nothing.
|
||||
//
|
||||
// Deliberately separate from apiRefusal even though today their code lists
|
||||
// overlap. apiRefusal's job is "map an error to user-facing text"; this one's is
|
||||
// "prove no set exists", which is what authorises releasing a name reservation
|
||||
// and dropping a write-ahead intent. Reusing apiRefusal for both would mean the
|
||||
// next person adding a code there for wording reasons silently converts it into
|
||||
// a strand-the-slug bug.
|
||||
//
|
||||
// Every code here is a request-validation refusal: Telegram rejected the call
|
||||
// before creating anything.
|
||||
func createRefused(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return contains(err.Error(),
|
||||
"PACK_SHORT_NAME_OCCUPIED", "already occupied",
|
||||
"PACK_SHORT_NAME_INVALID", "invalid sticker set name",
|
||||
"PACK_TITLE_INVALID",
|
||||
"STICKER_EMOJI_INVALID", "invalid sticker emojis",
|
||||
"too many emoji specified",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
testUser = int64(42)
|
||||
testChat = int64(1000)
|
||||
testSet = "mypack_by_testbot"
|
||||
otherSet = "someoneelse_by_testbot"
|
||||
)
|
||||
|
||||
var fixedNow = time.UnixMilli(1_700_000_000_000)
|
||||
|
||||
// newTestState builds a state over one in-memory collection, matching
|
||||
// production: both typed views share the collection, with disjoint key spaces.
|
||||
func newTestState() *state {
|
||||
coll := storage.NewMemoryProvider().Collection("sticker")
|
||||
return &state{
|
||||
store: storage.Typed[Pack](coll),
|
||||
pending: storage.Typed[PendingDelete](coll),
|
||||
slugs: storage.Typed[SlugReservation](coll),
|
||||
nowFn: func() time.Time { return fixedNow },
|
||||
}
|
||||
}
|
||||
|
||||
func seedPack(t *testing.T, s *state, count int) Pack {
|
||||
t.Helper()
|
||||
pack := Pack{
|
||||
Slug: "mypack", Name: testSet, Title: "My Pack",
|
||||
OwnerID: testUser, Count: count, CreatedAt: fixedNow.UnixMilli(),
|
||||
}
|
||||
if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil {
|
||||
t.Fatalf("seed pack: %v", err)
|
||||
}
|
||||
// A real pack always carries its name reservation; seeding without one
|
||||
// would let tests pass against a state production cannot reach.
|
||||
if err := s.slugs.Put(context.Background(), slugKey(pack.Slug),
|
||||
SlugReservation{Slug: pack.Slug, OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil {
|
||||
t.Fatalf("seed reservation: %v", err)
|
||||
}
|
||||
return pack
|
||||
}
|
||||
|
||||
// stickerReply builds a message replying to a sticker in setName.
|
||||
func stickerReply(text, setName string) *models.Update {
|
||||
upd := testutil.NewPrivateMessage(testUser, text)
|
||||
upd.Message.Chat.ID = testChat
|
||||
upd.Message.ReplyToMessage = &models.Message{
|
||||
Sticker: &models.Sticker{
|
||||
FileID: "file-in-" + setName,
|
||||
FileUniqueID: "uniq",
|
||||
Type: "regular",
|
||||
SetName: setName,
|
||||
Emoji: "🎉",
|
||||
},
|
||||
}
|
||||
return upd
|
||||
}
|
||||
|
||||
func loadPack(t *testing.T, s *state) (Pack, bool) {
|
||||
t.Helper()
|
||||
pack, found, err := getPack(context.Background(), s.store, testUser)
|
||||
if err != nil {
|
||||
t.Fatalf("load pack: %v", err)
|
||||
}
|
||||
return pack, found
|
||||
}
|
||||
|
||||
// methodsSent lists the API methods a run produced, so a test can assert both
|
||||
// what was called and that nothing was.
|
||||
func methodsSent(rb *testutil.RecordingBot) []string {
|
||||
var out []string
|
||||
for _, call := range rb.Sent() {
|
||||
out = append(out, call.Method)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countMethod(rb *testutil.RecordingBot, method string) int {
|
||||
n := 0
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method == method {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestAddSticker_HappyPath(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
|
||||
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
|
||||
if countMethod(rb, "addStickerToSet") != 1 {
|
||||
t.Fatalf("methods = %v, want one addStickerToSet", methodsSent(rb))
|
||||
}
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method != "addStickerToSet" {
|
||||
continue
|
||||
}
|
||||
if call.Form["name"] != testSet {
|
||||
t.Errorf("name = %q, want %q", call.Form["name"], testSet)
|
||||
}
|
||||
// UserID is always the caller: a non-owner never reaches this call.
|
||||
if call.Form["user_id"] != "42" {
|
||||
t.Errorf("user_id = %q, want 42", call.Form["user_id"])
|
||||
}
|
||||
if !strings.Contains(call.Form["sticker"], "😂") {
|
||||
t.Errorf("sticker payload %q missing the explicit emoji", call.Form["sticker"])
|
||||
}
|
||||
}
|
||||
|
||||
pack, _ := loadPack(t, s)
|
||||
if pack.Count != 4 {
|
||||
t.Errorf("Count = %d, want 4", pack.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit args beat the replied sticker's emoji, which beats the default.
|
||||
// addedStickerPayload returns the payload of the one addStickerToSet call the
|
||||
// handler is expected to have made.
|
||||
//
|
||||
// Ranging over rb.Sent() and asserting only inside an `if call.Method == ...`
|
||||
// makes the assertion vacuous: a handler that returns early and never calls
|
||||
// Telegram at all satisfies it, because the loop body never runs. Requiring
|
||||
// exactly one call is what makes these tests fail when the call disappears.
|
||||
func addedStickerPayload(t *testing.T, rb *testutil.RecordingBot) string {
|
||||
t.Helper()
|
||||
var payloads []string
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method == "addStickerToSet" {
|
||||
payloads = append(payloads, call.Form["sticker"])
|
||||
}
|
||||
}
|
||||
if len(payloads) != 1 {
|
||||
t.Fatalf("addStickerToSet calls = %d, want exactly 1", len(payloads))
|
||||
}
|
||||
return payloads[0]
|
||||
}
|
||||
|
||||
func TestAddSticker_EmojiPrecedence(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{"explicit wins", "/addsticker 🔥", "🔥"},
|
||||
{"inherits from replied sticker", "/addsticker", "🎉"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 0)
|
||||
|
||||
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply(tc.text, otherSet)); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
if payload := addedStickerPayload(t, rb); !strings.Contains(payload, tc.want) {
|
||||
t.Errorf("sticker payload %q, want emoji %q", payload, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With no emoji anywhere, the default keeps the call valid — Telegram rejects
|
||||
// an empty emoji_list.
|
||||
func TestAddSticker_FallsBackToDefaultEmoji(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 0)
|
||||
|
||||
upd := stickerReply("/addsticker", otherSet)
|
||||
upd.Message.ReplyToMessage.Sticker.Emoji = ""
|
||||
if err := s.handleAddSticker(context.Background(), rb.Bot, upd); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
if payload := addedStickerPayload(t, rb); !strings.Contains(payload, defaultEmoji) {
|
||||
t.Errorf("sticker payload %q, want the default emoji", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// A stray word is caught by parseEmoji. With no pack argument left, there is
|
||||
// nothing else it could have been mistaken for.
|
||||
func TestAddSticker_RejectsNonEmojiArgument(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 0)
|
||||
|
||||
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker mypack", otherSet)); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
if countMethod(rb, "addStickerToSet") != 0 {
|
||||
t.Errorf("methods = %v, want no API call", methodsSent(rb))
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "not an emoji") {
|
||||
t.Errorf("reply = %q, want an emoji usage error", rb.LastSent().Text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSticker_NoPackYet(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
|
||||
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
if countMethod(rb, "addStickerToSet") != 0 {
|
||||
t.Errorf("methods = %v, want no API call", methodsSent(rb))
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "/newpack") {
|
||||
t.Errorf("reply = %q, want it to point at /newpack", rb.LastSent().Text())
|
||||
}
|
||||
}
|
||||
|
||||
// A pending record means an unfinished /newpack: there is no usable pack yet,
|
||||
// and the reply has to say how to finish it.
|
||||
func TestAddSticker_PendingPackIsNotUsable(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
pack := seedPack(t, s, 0)
|
||||
pack.Pending = true
|
||||
if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil {
|
||||
t.Fatalf("seed pending: %v", err)
|
||||
}
|
||||
|
||||
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
if countMethod(rb, "addStickerToSet") != 0 {
|
||||
t.Errorf("methods = %v, want no API call", methodsSent(rb))
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "incomplete") {
|
||||
t.Errorf("reply = %q, want the incomplete-pack hint", rb.LastSent().Text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSticker_FullPack(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERS_TOO_MUCH")
|
||||
s := newTestState()
|
||||
seedPack(t, s, maxStickersPerPack)
|
||||
|
||||
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "full") {
|
||||
t.Errorf("reply = %q, want the pack-is-full message", rb.LastSent().Text())
|
||||
}
|
||||
// A failed add must not move the count.
|
||||
pack, _ := loadPack(t, s)
|
||||
if pack.Count != maxStickersPerPack {
|
||||
t.Errorf("Count = %d, want it unchanged at %d", pack.Count, maxStickersPerPack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelSticker_HappyPath(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
|
||||
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
|
||||
t.Fatalf("handleDelSticker: %v", err)
|
||||
}
|
||||
|
||||
if countMethod(rb, "deleteStickerFromSet") != 1 {
|
||||
t.Fatalf("methods = %v, want exactly one deleteStickerFromSet", methodsSent(rb))
|
||||
}
|
||||
pack, found := loadPack(t, s)
|
||||
if !found {
|
||||
t.Fatal("pack record deleted by a successful /delsticker")
|
||||
}
|
||||
if pack.Count != 2 {
|
||||
t.Errorf("Count = %d, want 2", pack.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// R7: a transient failure must never destroy the record. The probe that would
|
||||
// have done so was removed for exactly this reason.
|
||||
func TestDelSticker_TransientErrorKeepsRecord(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
rb.FailMethod("deleteStickerFromSet", 500, `{"ok":false,"description":"server exploded"}`)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
|
||||
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
|
||||
t.Fatalf("handleDelSticker: %v", err)
|
||||
}
|
||||
pack, found := loadPack(t, s)
|
||||
if !found {
|
||||
t.Fatal("a transient error deleted the pack record")
|
||||
}
|
||||
if pack.Count != 3 {
|
||||
t.Errorf("Count = %d, want it unchanged at 3", pack.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same rule: a *positive* STICKERSET_INVALID is the one
|
||||
// signal that authorises dropping the record, and dropping it is what unblocks
|
||||
// /newpack.
|
||||
func TestDelSticker_SetGoneDropsRecord(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
rb.FailMethodCode("deleteStickerFromSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
s := newTestState()
|
||||
seedPack(t, s, 1)
|
||||
|
||||
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
|
||||
t.Fatalf("handleDelSticker: %v", err)
|
||||
}
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("record survived a positive STICKERSET_INVALID; /newpack stays blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the last sticker may destroy the set Telegram-side, and /mypack
|
||||
// makes no API calls so it cannot notice. The reply has to name the way out.
|
||||
func TestDelSticker_EmptyPackNamesRecovery(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 1)
|
||||
|
||||
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
|
||||
t.Fatalf("handleDelSticker: %v", err)
|
||||
}
|
||||
text := rb.LastSent().Text()
|
||||
if !strings.Contains(text, "/delpack") {
|
||||
t.Errorf("reply = %q, want it to name /delpack", text)
|
||||
}
|
||||
pack, _ := loadPack(t, s)
|
||||
if pack.Count != 0 {
|
||||
t.Errorf("Count = %d, want 0", pack.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// Count is floored: a drifted record must not go negative.
|
||||
func TestDelSticker_CountFlooredAtZero(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 0)
|
||||
|
||||
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
|
||||
t.Fatalf("handleDelSticker: %v", err)
|
||||
}
|
||||
pack, _ := loadPack(t, s)
|
||||
if pack.Count != 0 {
|
||||
t.Errorf("Count = %d, want 0", pack.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditSticker_HappyPath(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 1)
|
||||
|
||||
if err := s.handleEditSticker(context.Background(), rb.Bot, stickerReply("/editsticker 😂🔥", testSet)); err != nil {
|
||||
t.Fatalf("handleEditSticker: %v", err)
|
||||
}
|
||||
if countMethod(rb, "setStickerEmojiList") != 1 {
|
||||
t.Fatalf("methods = %v, want one setStickerEmojiList", methodsSent(rb))
|
||||
}
|
||||
}
|
||||
|
||||
// An empty emoji_list is invalid, so unlike /addsticker this cannot fall back
|
||||
// to a default — the user has to say what they want.
|
||||
func TestEditSticker_RequiresEmoji(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 1)
|
||||
|
||||
if err := s.handleEditSticker(context.Background(), rb.Bot, stickerReply("/editsticker", testSet)); err != nil {
|
||||
t.Fatalf("handleEditSticker: %v", err)
|
||||
}
|
||||
if countMethod(rb, "setStickerEmojiList") != 0 {
|
||||
t.Errorf("methods = %v, want no API call", methodsSent(rb))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderSticker(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
wantAPI int
|
||||
}{
|
||||
{"zero is valid", "/ordersticker 0", 1},
|
||||
// Not bounded locally: Telegram validates against the current set size
|
||||
// and a local copy would go stale.
|
||||
{"large position reaches the API", "/ordersticker 999", 1},
|
||||
{"negative rejected locally", "/ordersticker -1", 0},
|
||||
{"non-numeric rejected locally", "/ordersticker first", 0},
|
||||
{"missing argument rejected locally", "/ordersticker", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 5)
|
||||
|
||||
if err := s.handleOrderSticker(context.Background(), rb.Bot, stickerReply(tc.text, testSet)); err != nil {
|
||||
t.Fatalf("handleOrderSticker: %v", err)
|
||||
}
|
||||
if got := countMethod(rb, "setStickerPositionInSet"); got != tc.wantAPI {
|
||||
t.Errorf("setStickerPositionInSet calls = %d, want %d (methods %v)", got, tc.wantAPI, methodsSent(rb))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The self-heal path frees the name too: STICKERSET_INVALID is a positive
|
||||
// "this set is gone", so there is nothing left for the reservation to protect.
|
||||
func TestSelfHeal_ReleasesTheName(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
s := newTestState()
|
||||
seedPack(t, s, 2)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.handleAddSticker(ctx, rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("pack record survived a positive STICKERSET_INVALID")
|
||||
}
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
|
||||
t.Error("the name is still reserved after the set was confirmed gone")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
|
||||
// Registered for their side effect: image.Decode needs the formats
|
||||
// Telegram actually delivers.
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // read-only WEBP decoder
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// stickerEdge is Telegram's requirement: one side exactly 512px, the other
|
||||
// at most 512px.
|
||||
stickerEdge = 512
|
||||
// thumbnailEdge is the pack thumbnail requirement: exactly 100x100.
|
||||
thumbnailEdge = 100
|
||||
|
||||
// maxDecodeDimension bounds peak allocation. Checked via DecodeConfig,
|
||||
// before any pixel buffer exists.
|
||||
//
|
||||
// Note the 2 MB source cap bounds *compressed* bytes and so does not bound
|
||||
// this: a flat-colour 4096x4096 PNG is a few tens of KB. At this cap a
|
||||
// single conversion peaks near 90 MB live — the decoded source, the NRGBA
|
||||
// scale target, and the PNG encode buffer coexist. Handlers run one at a
|
||||
// time, so this is RSS pressure rather than a multiplier.
|
||||
maxDecodeDimension = 4096
|
||||
|
||||
// softMaxStickerBytes is a *client-side* ceiling, not a documented limit.
|
||||
// The widely-repeated 512 KB figure appears in no current official page, so
|
||||
// it must never be stated to users as a rule.
|
||||
softMaxStickerBytes = 512 << 10
|
||||
)
|
||||
|
||||
// toStickerPNG converts an arbitrary image to a PNG sized for a static sticker:
|
||||
// long edge exactly 512, aspect ratio preserved.
|
||||
func toStickerPNG(src []byte) ([]byte, error) {
|
||||
img, err := decodeBounded(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
w, h := scaleToLongEdge(b.Dx(), b.Dy(), stickerEdge)
|
||||
out := resize(img, w, h)
|
||||
|
||||
data, err := encodePNG(out, png.DefaultCompression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) <= softMaxStickerBytes {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Try harder before losing resolution.
|
||||
if data, err = encodePNG(out, png.BestCompression); err == nil && len(data) <= softMaxStickerBytes {
|
||||
return data, nil
|
||||
}
|
||||
// Then step the long edge down. Below 320 the sticker is too small to be
|
||||
// worth shrinking further; hand back the best effort instead.
|
||||
//
|
||||
// Each rung resamples the already-scaled 512px image, not the source. The
|
||||
// source may be 4096x4096, and resampling it once per rung made the ladder
|
||||
// cost four full-size resamples instead of one — seconds of uninterruptible
|
||||
// CPU on a dispatcher that runs handlers one at a time, so a single user
|
||||
// could stall the bot for everyone. Target dimensions still come from the
|
||||
// original ratio, so the aspect is identical either way.
|
||||
for _, edge := range []int{448, 384, 320} {
|
||||
w, h = scaleToLongEdge(b.Dx(), b.Dy(), edge)
|
||||
candidate, encErr := encodePNG(resize(out, w, h), png.BestCompression)
|
||||
if encErr != nil {
|
||||
return nil, encErr
|
||||
}
|
||||
data = candidate
|
||||
if len(data) <= softMaxStickerBytes {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
log.Error("sticker_image_oversized", "bytes", len(data))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// toThumbnailPNG converts an image to a pack thumbnail: exactly 100x100, with
|
||||
// the short edge padded transparently so the aspect ratio survives.
|
||||
func toThumbnailPNG(src []byte) ([]byte, error) {
|
||||
img, err := decodeBounded(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
w, h := scaleToLongEdge(b.Dx(), b.Dy(), thumbnailEdge)
|
||||
scaled := resize(img, w, h)
|
||||
|
||||
canvas := image.NewNRGBA(image.Rect(0, 0, thumbnailEdge, thumbnailEdge))
|
||||
offset := image.Pt((thumbnailEdge-w)/2, (thumbnailEdge-h)/2)
|
||||
draw.Draw(canvas, scaled.Bounds().Add(offset), scaled, image.Point{}, draw.Src)
|
||||
|
||||
return encodePNG(canvas, png.BestCompression)
|
||||
}
|
||||
|
||||
// decodeBounded reads the header first and refuses oversized images before any
|
||||
// pixel buffer is allocated.
|
||||
func decodeBounded(src []byte) (image.Image, error) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return nil, refuse("That file is not an image Telegram can use. Send a PNG, JPEG or WEBP.")
|
||||
}
|
||||
if cfg.Width > maxDecodeDimension || cfg.Height > maxDecodeDimension {
|
||||
return nil, refuse("That image is too big to process. Keep both sides under 4096 pixels.")
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
// Not "too large" — a header claiming no pixels at all. The user can
|
||||
// act on this, so it must be a userError rather than surfacing as a
|
||||
// generic failure with an ERROR log line.
|
||||
return nil, refuse("That image has no usable dimensions. Send a normal PNG, JPEG or WEBP.")
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return nil, refuse("That image could not be read. Send a PNG, JPEG or WEBP.")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// scaleToLongEdge returns the dimensions that put the long edge exactly at
|
||||
// edge, keeping the aspect ratio. The short edge is clamped to at least 1 so an
|
||||
// extreme aspect ratio cannot produce a zero-dimension image.
|
||||
func scaleToLongEdge(w, h, edge int) (int, int) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return edge, edge
|
||||
}
|
||||
if w >= h {
|
||||
short := int(float64(h)*float64(edge)/float64(w) + 0.5)
|
||||
return edge, max(short, 1)
|
||||
}
|
||||
short := int(float64(w)*float64(edge)/float64(h) + 0.5)
|
||||
return max(short, 1), edge
|
||||
}
|
||||
|
||||
// resize scales into a fresh NRGBA, which preserves alpha.
|
||||
func resize(img image.Image, w, h int) *image.NRGBA {
|
||||
out := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
draw.CatmullRom.Scale(out, out.Bounds(), img, img.Bounds(), draw.Over, nil)
|
||||
return out
|
||||
}
|
||||
|
||||
func encodePNG(img image.Image, level png.CompressionLevel) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := png.Encoder{CompressionLevel: level}
|
||||
if err := enc.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makePNG generates a test image in-process, so no binary fixtures are
|
||||
// committed and every case is readable from the test itself.
|
||||
func makePNG(t *testing.T, w, h int, alpha uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: alpha})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode fixture: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func decodeSize(t *testing.T, data []byte) (int, int) {
|
||||
t.Helper()
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("decode result: %v", err)
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
// Telegram requires one side to be exactly 512 and the other at most 512.
|
||||
func TestToStickerPNG_Geometry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h int
|
||||
wantW, wantH int
|
||||
}{
|
||||
{"landscape", 1024, 512, 512, 256},
|
||||
{"portrait", 300, 900, 171, 512},
|
||||
{"square", 512, 512, 512, 512},
|
||||
{"upscales small input", 64, 32, 512, 256},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := toStickerPNG(makePNG(t, tc.w, tc.h, 255))
|
||||
if err != nil {
|
||||
t.Fatalf("toStickerPNG: %v", err)
|
||||
}
|
||||
gotW, gotH := decodeSize(t, out)
|
||||
if gotW != tc.wantW || gotH != tc.wantH {
|
||||
t.Errorf("%dx%d -> %dx%d, want %dx%d", tc.w, tc.h, gotW, gotH, tc.wantW, tc.wantH)
|
||||
}
|
||||
if gotW != stickerEdge && gotH != stickerEdge {
|
||||
t.Errorf("neither side is exactly %d: %dx%d", stickerEdge, gotW, gotH)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An extreme aspect ratio must not round the short edge down to zero, which
|
||||
// would produce an invalid image rather than an error.
|
||||
//
|
||||
// 1x4000 rather than the plan's 1x5000: 5000 is past maxDecodeDimension, so
|
||||
// that case never reaches the scaler at all — it is rejected by the guard
|
||||
// below. The clamp still needs exercising, and this is the most extreme ratio
|
||||
// that actually gets there (512/4000 rounds to 0 before clamping).
|
||||
func TestToStickerPNG_ExtremeAspectRatio(t *testing.T) {
|
||||
out, err := toStickerPNG(makePNG(t, 1, 4000, 255))
|
||||
if err != nil {
|
||||
t.Fatalf("toStickerPNG: %v", err)
|
||||
}
|
||||
w, h := decodeSize(t, out)
|
||||
if w < 1 || h < 1 {
|
||||
t.Fatalf("got a zero-dimension image: %dx%d", w, h)
|
||||
}
|
||||
if h != stickerEdge {
|
||||
t.Errorf("long edge = %d, want %d", h, stickerEdge)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: past the cap, the guard refuses rather than the scaler
|
||||
// coping. The bound exists to cap allocation, so it must win over any
|
||||
// aspect-ratio handling.
|
||||
func TestToStickerPNG_ExtremeAspectPastCapIsRejected(t *testing.T) {
|
||||
if _, err := toStickerPNG(makePNG(t, 1, maxDecodeDimension+904, 255)); err == nil {
|
||||
t.Fatal("an image past the dimension cap was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Stickers are cut-outs; losing alpha in the resize would put a black box
|
||||
// behind every one of them.
|
||||
func TestToStickerPNG_PreservesAlpha(t *testing.T) {
|
||||
out, err := toStickerPNG(makePNG(t, 256, 256, 0))
|
||||
if err != nil {
|
||||
t.Fatalf("toStickerPNG: %v", err)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, _, _, a := img.At(128, 128).RGBA(); a != 0 {
|
||||
t.Errorf("alpha = %d at a fully transparent pixel, want 0", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToThumbnailPNG_IsExactly100Square(t *testing.T) {
|
||||
// A non-square input still has to come out exactly 100x100, padded.
|
||||
out, err := toThumbnailPNG(makePNG(t, 800, 400, 255))
|
||||
if err != nil {
|
||||
t.Fatalf("toThumbnailPNG: %v", err)
|
||||
}
|
||||
w, h := decodeSize(t, out)
|
||||
if w != thumbnailEdge || h != thumbnailEdge {
|
||||
t.Errorf("thumbnail = %dx%d, want %dx%d", w, h, thumbnailEdge, thumbnailEdge)
|
||||
}
|
||||
}
|
||||
|
||||
// The dimension guard runs on the header, before any pixel buffer exists — it
|
||||
// is what bounds peak allocation for attacker-supplied images.
|
||||
func TestDecodeBounded_RejectsOversizedDimensions(t *testing.T) {
|
||||
oversized := makePNG(t, maxDecodeDimension+1, 4, 255)
|
||||
if _, err := toStickerPNG(oversized); err == nil {
|
||||
t.Fatal("toStickerPNG accepted an image past the dimension cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBounded_RejectsNonImage(t *testing.T) {
|
||||
if _, err := toStickerPNG([]byte("this is not an image")); err == nil {
|
||||
t.Fatal("toStickerPNG accepted non-image bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaleToLongEdge(t *testing.T) {
|
||||
cases := []struct {
|
||||
w, h, edge int
|
||||
wantW, wantH int
|
||||
}{
|
||||
{1000, 500, 512, 512, 256},
|
||||
{500, 1000, 512, 256, 512},
|
||||
{100, 100, 512, 512, 512},
|
||||
{5000, 1, 512, 512, 1}, // clamped, never 0
|
||||
}
|
||||
for _, tc := range cases {
|
||||
gotW, gotH := scaleToLongEdge(tc.w, tc.h, tc.edge)
|
||||
if gotW != tc.wantW || gotH != tc.wantH {
|
||||
t.Errorf("scaleToLongEdge(%d, %d, %d) = (%d, %d), want (%d, %d)",
|
||||
tc.w, tc.h, tc.edge, gotW, gotH, tc.wantW, tc.wantH)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package sticker lets any user create and manage one personal Telegram
|
||||
// sticker pack through the bot. The pack is created on behalf of the calling
|
||||
// user, named "<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
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
newpackUsage = "Reply to a sticker with: /newpack <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)))
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
const getMeResult = `{"id":7,"is_bot":true,"first_name":"Test","username":"testbot"}`
|
||||
|
||||
// stubBotIdentity makes the username resolver work. The bot starts with
|
||||
// WithSkipGetMe, so nothing populates a username until the module asks.
|
||||
func stubBotIdentity(rb *testutil.RecordingBot) { rb.StubMethod("getMe", getMeResult) }
|
||||
|
||||
// setMissing makes getStickerSet report the set does not exist — the only
|
||||
// classification that lets the module treat a slug as free.
|
||||
func setMissing(rb *testutil.RecordingBot) {
|
||||
rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
}
|
||||
|
||||
// setExists makes getStickerSet return a real set, which needs a struct result
|
||||
// the bare harness cannot produce.
|
||||
func setExists(rb *testutil.RecordingBot) {
|
||||
rb.StubMethod("getStickerSet", `{"name":"`+testSet+`","title":"My Pack","sticker_type":"regular","stickers":[]}`)
|
||||
}
|
||||
|
||||
func TestNewPack_HappyPath(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setMissing(rb)
|
||||
s := newTestState()
|
||||
|
||||
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
if countMethod(rb, "createNewStickerSet") != 1 {
|
||||
t.Fatalf("methods = %v, want one createNewStickerSet", methodsSent(rb))
|
||||
}
|
||||
pack, found := loadPack(t, s)
|
||||
if !found {
|
||||
t.Fatal("no pack record after a successful /newpack")
|
||||
}
|
||||
if pack.Pending {
|
||||
t.Error("record is still Pending after success")
|
||||
}
|
||||
if pack.Name != testSet || pack.Count != 1 {
|
||||
t.Errorf("pack = %+v, want name %q and count 1", pack, testSet)
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), shareLink(testSet)) {
|
||||
t.Errorf("reply = %q, want the share link", rb.LastSent().Text())
|
||||
}
|
||||
}
|
||||
|
||||
// The quota is the create-only write itself: there is no separate counter, so a
|
||||
// second /newpack must lose on PutVersioned and never reach the API.
|
||||
func TestNewPack_SecondPackRefusedWithoutAPICall(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 5)
|
||||
|
||||
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack another Another", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
if countMethod(rb, "createNewStickerSet") != 0 || countMethod(rb, "getStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want no sticker-set API calls", methodsSent(rb))
|
||||
}
|
||||
text := rb.LastSent().Text()
|
||||
if !strings.Contains(text, "mypack") || !strings.Contains(text, "/delpack") {
|
||||
t.Errorf("reply = %q, want it to name the existing slug and /delpack", text)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-running the same command after an interruption must complete the pack,
|
||||
// not report the slug taken. This is what keeps a crash from stranding a
|
||||
// permanent URL.
|
||||
// An interrupted attempt whose set DOES exist is refused, not resumed.
|
||||
//
|
||||
// This used to adopt. Adoption is gone: no local fact can prove a set belongs
|
||||
// to the caller, because the store holding that fact is exactly what a restart
|
||||
// on the in-memory backend erases while the packs survive. The cost is that a
|
||||
// crash between creation and commit strands the set; the benefit is that the
|
||||
// same evidence cannot be manufactured by an attacker.
|
||||
func TestNewPack_InterruptedAttemptWithLiveSetIsRefused(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setExists(rb)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
seedInterrupted(t, s, "mypack", testSet)
|
||||
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
if countMethod(rb, "createNewStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want no create — a set already exists under that name", methodsSent(rb))
|
||||
}
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, slugTaken) {
|
||||
t.Errorf("reply = %q, want the name-taken refusal", got)
|
||||
}
|
||||
if pack, found := loadPack(t, s); found && !pack.Pending {
|
||||
t.Errorf("adopted the existing set: %+v", pack)
|
||||
}
|
||||
// Nothing left behind: a pending record naming a set the caller may not own
|
||||
// is a delete primitive, since /delpack deletes by set name.
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("refusal kept the intent — /delpack could then aim it at that set")
|
||||
}
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
|
||||
t.Error("refusal kept the reservation")
|
||||
}
|
||||
}
|
||||
|
||||
// A pending record under a *different* slug whose set exists is likewise not
|
||||
// adopted; the caller proceeds under the name they asked for, and the stranded
|
||||
// name stays reserved because a set really does occupy it.
|
||||
func TestNewPack_DifferentSlugDoesNotAdoptExistingSet(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setExists(rb)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
seedInterrupted(t, s, "oldslug", testSet)
|
||||
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newslug New", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
if pack, found := loadPack(t, s); found && pack.Slug == "oldslug" && !pack.Pending {
|
||||
t.Errorf("adopted the old set: %+v", pack)
|
||||
}
|
||||
// The old name stays claimed: a set exists under it, so it is not free.
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "oldslug"); !held {
|
||||
t.Error("released a name that still has a set behind it")
|
||||
}
|
||||
}
|
||||
|
||||
// Same shape, but nothing was created under the old name: the record is free to
|
||||
// take over.
|
||||
func TestNewPack_DifferentSlugReplacesDeadIntent(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setMissing(rb)
|
||||
s := newTestState()
|
||||
|
||||
seedInterrupted(t, s, "oldslug", "oldslug_by_testbot")
|
||||
|
||||
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
if countMethod(rb, "createNewStickerSet") != 1 {
|
||||
t.Fatalf("methods = %v, want one create", methodsSent(rb))
|
||||
}
|
||||
pack, _ := loadPack(t, s)
|
||||
if pack.Slug != "mypack" || pack.Pending {
|
||||
t.Errorf("pack = %+v, want a confirmed mypack record", pack)
|
||||
}
|
||||
// The old name had nothing behind it, so it must return to the pool.
|
||||
// Without this assertion the test was named for a behaviour it never
|
||||
// checked: every abandoned attempt would quietly shrink the namespace.
|
||||
if _, held, _ := getSlugReservation(context.Background(), s.slugs, "oldslug"); held {
|
||||
t.Error("the dead name stayed reserved with no set behind it")
|
||||
}
|
||||
}
|
||||
|
||||
// An unclassifiable getStickerSet failure means "unknown". Guessing either way
|
||||
// is what strands slugs or orphans sets, so the handler must change nothing.
|
||||
func TestNewPack_UnknownLookupErrorAborts(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`)
|
||||
s := newTestState()
|
||||
|
||||
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
if countMethod(rb, "createNewStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want no create after an unknown error", methodsSent(rb))
|
||||
}
|
||||
// The intent and reservation must SURVIVE. "Unknown" means the set may
|
||||
// exist; destroying either here is what permanently strands a slug. Keeping
|
||||
// them is what makes re-running the same command recover.
|
||||
pack, found := loadPack(t, s)
|
||||
if !found || !pack.Pending {
|
||||
t.Errorf("intent = (%+v, found=%v), want it kept and still pending for re-run recovery", pack, found)
|
||||
}
|
||||
if _, held, _ := getSlugReservation(context.Background(), s.slugs, "mypack"); !held {
|
||||
t.Error("reservation dropped on an unknown error; another user could take the name while the set may exist")
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram itself refusing the name — NOT the "another user of this bot holds
|
||||
// it" case, which the reservation now settles before any API call (see
|
||||
// TestNewPack_ForeignReservationRefusedBeforeAnyAPICall).
|
||||
//
|
||||
// The reachable path here is a short name Telegram still reserves after a
|
||||
// delete (plan R11): our reservation is free, GetStickerSet says missing, and
|
||||
// createNewStickerSet refuses. A classified refusal proves nothing was created,
|
||||
// so both the intent and the reservation are released for a retry.
|
||||
func TestNewPack_OccupiedSlugDropsIntent(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setMissing(rb)
|
||||
rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_OCCUPIED")
|
||||
s := newTestState()
|
||||
|
||||
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("intent survived a rejected create")
|
||||
}
|
||||
if _, held, _ := getSlugReservation(context.Background(), s.slugs, "mypack"); held {
|
||||
t.Error("reservation survived a classified refusal; the name would be held with no set behind it")
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "taken") {
|
||||
t.Errorf("reply = %q, want the slug-taken message", rb.LastSent().Text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPack_ValidatesInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{"no arguments", "/newpack"},
|
||||
{"slug only", "/newpack mypack"},
|
||||
{"leading digit", "/newpack 1pack Title"},
|
||||
{"double underscore", "/newpack my__pack Title"},
|
||||
{"trailing underscore", "/newpack mypack_ Title"},
|
||||
{"too short", "/newpack ab Title"},
|
||||
{"title too long", "/newpack mypack " + strings.Repeat("x", maxTitleLen+1)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setMissing(rb)
|
||||
s := newTestState()
|
||||
|
||||
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply(tc.text, otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
if countMethod(rb, "createNewStickerSet") != 0 {
|
||||
t.Errorf("methods = %v, want rejection before any create", methodsSent(rb))
|
||||
}
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("a rejected /newpack wrote a record")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyPack_MakesNoAPICalls(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 7)
|
||||
|
||||
upd := testutil.NewPrivateMessage(testUser, "/mypack")
|
||||
if err := s.handleMyPack(context.Background(), rb.Bot, upd); err != nil {
|
||||
t.Fatalf("handleMyPack: %v", err)
|
||||
}
|
||||
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method != "sendMessage" {
|
||||
t.Errorf("unexpected API call %q; /mypack must read only the store", call.Method)
|
||||
}
|
||||
}
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{"My Pack", "mypack", "7", shareLink(testSet)} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("reply %q missing %q", text, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stranded attempt is shown, not hidden: it blocks /newpack, and re-running
|
||||
// the same command is what clears it.
|
||||
func TestMyPack_ShowsPendingMarker(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
pack := seedPack(t, s, 0)
|
||||
pack.Pending = true
|
||||
if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil {
|
||||
t.Fatalf("seed pending: %v", err)
|
||||
}
|
||||
|
||||
if err := s.handleMyPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/mypack")); err != nil {
|
||||
t.Fatalf("handleMyPack: %v", err)
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "incomplete") {
|
||||
t.Errorf("reply = %q, want the incomplete marker", rb.LastSent().Text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyPack_NoPack(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
|
||||
if err := s.handleMyPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/mypack")); err != nil {
|
||||
t.Fatalf("handleMyPack: %v", err)
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "/newpack") {
|
||||
t.Errorf("reply = %q, want it to point at /newpack", rb.LastSent().Text())
|
||||
}
|
||||
}
|
||||
|
||||
// The link cannot follow a rename, so the reply has to name the only route to a
|
||||
// different one — otherwise the user is left at a dead end.
|
||||
func TestRenamePack_NamesTheURLChangeRoute(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 4)
|
||||
|
||||
if err := s.handleRenamePack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/renamepack Better Name")); err != nil {
|
||||
t.Fatalf("handleRenamePack: %v", err)
|
||||
}
|
||||
if countMethod(rb, "setStickerSetTitle") != 1 {
|
||||
t.Fatalf("methods = %v, want one setStickerSetTitle", methodsSent(rb))
|
||||
}
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{"Better Name", shareLink(testSet), "/delpack", "/newpack"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("reply %q missing %q", text, want)
|
||||
}
|
||||
}
|
||||
pack, _ := loadPack(t, s)
|
||||
if pack.Title != "Better Name" {
|
||||
t.Errorf("Title = %q, want the new title committed", pack.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePack_NoPack(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
|
||||
if err := s.handleRenamePack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/renamepack Whatever")); err != nil {
|
||||
t.Fatalf("handleRenamePack: %v", err)
|
||||
}
|
||||
if countMethod(rb, "setStickerSetTitle") != 0 {
|
||||
t.Errorf("methods = %v, want no API call", methodsSent(rb))
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous senders are refused before any store or API access, on every
|
||||
// command. Telegram gives every anonymous admin the same From.ID, so without
|
||||
// this they would all share one pack — and under one-pack-per-user, the first
|
||||
// one to run /newpack would own it and block the rest.
|
||||
func TestHandlers_RefuseAnonymousSenders(t *testing.T) {
|
||||
handlers := map[string]struct {
|
||||
text string
|
||||
run func(*state, context.Context, *bot.Bot, *models.Update) error
|
||||
}{
|
||||
"newpack": {"/newpack mypack My Pack", (*state).handleNewPack},
|
||||
"mypack": {"/mypack", (*state).handleMyPack},
|
||||
"addsticker": {"/addsticker 😂", (*state).handleAddSticker},
|
||||
"delsticker": {"/delsticker", (*state).handleDelSticker},
|
||||
"editsticker": {"/editsticker 😂", (*state).handleEditSticker},
|
||||
"ordersticker": {"/ordersticker 0", (*state).handleOrderSticker},
|
||||
"renamepack": {"/renamepack Title", (*state).handleRenamePack},
|
||||
"delpack": {"/delpack", (*state).handleDelPack},
|
||||
"setpackicon": {"/setpackicon", (*state).handleSetPackIcon},
|
||||
}
|
||||
for name, h := range handlers {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setMissing(rb)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 3)
|
||||
|
||||
upd := stickerReply(h.text, testSet)
|
||||
upd.Message.SenderChat = &models.Chat{ID: -100}
|
||||
|
||||
if err := h.run(s, context.Background(), rb.Bot, upd); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method != "sendMessage" {
|
||||
t.Errorf("%s called %q for an anonymous sender", name, call.Method)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "personal account") {
|
||||
t.Errorf("%s reply = %q, want the anonymous-sender refusal", name, rb.LastSent().Text())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// seedInterrupted recreates the state a crashed /newpack leaves behind: a
|
||||
// pending pack record AND the global name reservation that always precedes it.
|
||||
// Seeding the record alone would build a state production cannot reach.
|
||||
func seedInterrupted(t *testing.T, s *state, slug, setName string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
pending := Pack{Slug: slug, Name: setName, Title: "Old", OwnerID: testUser, Pending: true}
|
||||
if err := s.store.Put(ctx, packKey(testUser), pending); err != nil {
|
||||
t.Fatalf("seed pending: %v", err)
|
||||
}
|
||||
if err := s.slugs.Put(ctx, slugKey(slug),
|
||||
SlugReservation{Slug: slug, OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil {
|
||||
t.Fatalf("seed reservation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The pack-takeover regression. Share links are public, so any user can read a
|
||||
// pack's slug off t.me and try to claim it. Before the global reservation, an
|
||||
// attacker with no pack of their own reached createOrAdopt, found the victim's
|
||||
// set existing, and adopted it — after which /delpack destroyed the victim's
|
||||
// pack.
|
||||
//
|
||||
// The attacker must be refused, and must leave no trace: no adoption, no record
|
||||
// of their own, and the victim's reservation untouched.
|
||||
func TestNewPack_CannotSeizeAnotherUsersPack(t *testing.T) {
|
||||
const victim, attacker = int64(1), int64(2)
|
||||
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setExists(rb) // the victim's set resolves
|
||||
s := newTestState()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := s.store.Put(ctx, packKey(victim),
|
||||
Pack{Slug: "victimpack", Name: "victimpack_by_testbot", Title: "Victim", OwnerID: victim, Count: 9}); err != nil {
|
||||
t.Fatalf("seed victim pack: %v", err)
|
||||
}
|
||||
if err := s.slugs.Put(ctx, slugKey("victimpack"),
|
||||
SlugReservation{Slug: "victimpack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil {
|
||||
t.Fatalf("seed victim reservation: %v", err)
|
||||
}
|
||||
|
||||
upd := stickerReply("/newpack victimpack Mine Now", otherSet)
|
||||
upd.Message.From.ID = attacker
|
||||
if err := s.handleNewPack(ctx, rb.Bot, upd); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(rb.LastSent().Text(), "taken") {
|
||||
t.Errorf("reply = %q, want the name refused as taken", rb.LastSent().Text())
|
||||
}
|
||||
if got, found, _ := getPack(ctx, s.store, attacker); found {
|
||||
t.Errorf("attacker now holds a pack record %+v — takeover succeeded", got)
|
||||
}
|
||||
held, _, _ := getSlugReservation(ctx, s.slugs, "victimpack")
|
||||
if held.OwnerID != victim {
|
||||
t.Errorf("reservation owner = %d, want the victim (%d)", held.OwnerID, victim)
|
||||
}
|
||||
// The victim's own record must be exactly as it was.
|
||||
pack, found, _ := getPack(ctx, s.store, victim)
|
||||
if !found || pack.Count != 9 || pack.Title != "Victim" {
|
||||
t.Errorf("victim pack = (%+v, found=%v), want it untouched", pack, found)
|
||||
}
|
||||
}
|
||||
|
||||
// The same protection has to hold for the resume path: a pending record whose
|
||||
// slug is reserved by somebody else must not adopt either.
|
||||
func TestNewPack_StaleIntentCannotAdoptForeignName(t *testing.T) {
|
||||
const victim, attacker = int64(1), int64(2)
|
||||
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setExists(rb)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
// The attacker holds a pending record naming the victim's set, but the
|
||||
// reservation belongs to the victim.
|
||||
if err := s.store.Put(ctx, packKey(attacker),
|
||||
Pack{Slug: "victimpack", Name: "victimpack_by_testbot", Title: "Old", OwnerID: attacker, Pending: true}); err != nil {
|
||||
t.Fatalf("seed attacker intent: %v", err)
|
||||
}
|
||||
if err := s.slugs.Put(ctx, slugKey("victimpack"),
|
||||
SlugReservation{Slug: "victimpack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil {
|
||||
t.Fatalf("seed victim reservation: %v", err)
|
||||
}
|
||||
|
||||
upd := stickerReply("/newpack otherslug Other", otherSet)
|
||||
upd.Message.From.ID = attacker
|
||||
if err := s.handleNewPack(ctx, rb.Bot, upd); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
pack, _, _ := getPack(ctx, s.store, attacker)
|
||||
if pack.Name == "victimpack_by_testbot" && !pack.Pending {
|
||||
t.Errorf("attacker adopted the victim's set via the stale-intent path: %+v", pack)
|
||||
}
|
||||
held, _, _ := getSlugReservation(ctx, s.slugs, "victimpack")
|
||||
if held.OwnerID != victim {
|
||||
t.Errorf("reservation owner = %d, want the victim (%d)", held.OwnerID, victim)
|
||||
}
|
||||
}
|
||||
|
||||
// F5's replacement: a name held by another user is now refused by the
|
||||
// reservation, before any API call. The previous test of this name stubbed a
|
||||
// combination (set missing + PACK_SHORT_NAME_OCCUPIED) that cannot occur for a
|
||||
// set another user holds, so it never covered this case.
|
||||
func TestNewPack_ForeignReservationRefusedBeforeAnyAPICall(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.slugs.Put(ctx, slugKey("mypack"),
|
||||
SlugReservation{Slug: "mypack", OwnerID: 999, CreatedAt: fixedNow.UnixMilli()}); err != nil {
|
||||
t.Fatalf("seed reservation: %v", err)
|
||||
}
|
||||
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method != "sendMessage" && call.Method != "getMe" {
|
||||
t.Errorf("called %q for a name held by another user; want refusal before any sticker API call", call.Method)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "taken") {
|
||||
t.Errorf("reply = %q, want the taken refusal", rb.LastSent().Text())
|
||||
}
|
||||
}
|
||||
|
||||
// The name-burning regression. Reservations are permanent and global, so
|
||||
// writing one before establishing the caller is even entitled to a pack turned
|
||||
// every refused /newpack into a free, unlimited denial primitive: no API call,
|
||||
// no cost, and the name is gone for everyone else forever.
|
||||
func TestNewPack_RefusedRunsClaimNoNames(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setMissing(rb)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
seedPack(t, s, 3) // the caller already has a finished pack
|
||||
|
||||
for _, name := range []string{"memes", "funny", "cats"} {
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack "+name+" x", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack(%s): %v", name, err)
|
||||
}
|
||||
if !strings.Contains(rb.LastSent().Text(), "already have a pack") {
|
||||
t.Fatalf("reply = %q, want the already-have-a-pack refusal", rb.LastSent().Text())
|
||||
}
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, name); held {
|
||||
t.Errorf("refused /newpack claimed %q — every other user is now permanently denied that name", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the real pack's own name is reserved.
|
||||
keys, err := s.slugs.List(ctx, slugPrefix)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Errorf("reservations = %d (%v), want exactly the one backing the real pack", len(keys), keys)
|
||||
}
|
||||
}
|
||||
|
||||
// A name is also not burned when the *set name* is unusable, or when anything
|
||||
// else makes the command bail after reserving.
|
||||
func TestNewPack_ReservationReleasedWhenClaimFails(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setMissing(rb)
|
||||
rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_INVALID")
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
|
||||
t.Error("reservation survived a refusal that proves nothing was created")
|
||||
}
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("intent survived a refusal that proves nothing was created")
|
||||
}
|
||||
}
|
||||
|
||||
// These two are the only coverage of the `created` flag itself.
|
||||
//
|
||||
// A predecessor named ...ResumedReservationSurvivesABail sat here and did not
|
||||
// test its name: its bail happened after claimSlug, which never consults the
|
||||
// flag, so it passed with the whole distinction deleted. It duplicated
|
||||
// TestNewPack_UnknownLookupErrorAborts and has been removed.
|
||||
//
|
||||
// Both drive a bail *inside* claimSlug, which is the single place handleNewPack
|
||||
// consults `created`. The other reservation tests bail later — in createOrAdopt
|
||||
// — where a different mechanism (createRefused) does the releasing, so they
|
||||
// pass with the `created` guard removed entirely and cannot pin it.
|
||||
//
|
||||
// Reaching claimSlug's bail takes a pending record under a *different* slug:
|
||||
// that sends claimSlug into resolveStaleIntent, which gives up when it cannot
|
||||
// establish what happened to the old set.
|
||||
func seedStaleIntentBail(t *testing.T, rb *testutil.RecordingBot, s *state) {
|
||||
t.Helper()
|
||||
stubBotIdentity(rb)
|
||||
// Unknown failure probing the *old* set: resolveStaleIntent refuses to
|
||||
// guess, so handleNewPack bails holding whatever reserveSlug just did.
|
||||
rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`)
|
||||
seedInterrupted(t, s, "oldname", otherSet)
|
||||
}
|
||||
|
||||
// Direction 1: a name this invocation reserved must not survive the bail.
|
||||
// Without the release, every refused attempt burns a global name for everyone.
|
||||
func TestNewPack_FreshReservationReleasedWhenClaimBails(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
seedStaleIntentBail(t, rb, s)
|
||||
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newname New Pack", testSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "newname"); held {
|
||||
t.Error("a name reserved by this invocation survived its bail — the name is now permanently denied to every other user, with no pack behind it")
|
||||
}
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "oldname"); !held {
|
||||
t.Error("the pre-existing reservation was collateral damage")
|
||||
}
|
||||
}
|
||||
|
||||
// Direction 2: a name the caller already held must survive the bail. Releasing
|
||||
// it would hand a live claim to the next user to ask while the set may exist.
|
||||
func TestNewPack_ResumedReservationNotReleasedWhenClaimBails(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
seedStaleIntentBail(t, rb, s)
|
||||
|
||||
// The caller already holds "newname" from an earlier run, so reserveSlug
|
||||
// resumes it rather than creating it.
|
||||
if err := s.slugs.Put(ctx, slugKey("newname"),
|
||||
SlugReservation{Slug: "newname", OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil {
|
||||
t.Fatalf("seed prior reservation: %v", err)
|
||||
}
|
||||
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newname New Pack", testSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "newname"); !held {
|
||||
t.Error("a reservation that predates this command was released on its bail — another user can now claim a name whose set may already exist")
|
||||
}
|
||||
}
|
||||
|
||||
// A reservation is only proof of ownership while it outlives the sets it
|
||||
// guards, and it does not: reservations live in our store, packs live at
|
||||
// Telegram, and a restart on the in-memory backend wipes the former while every
|
||||
// pack survives. This is the takeover of TestNewPack_CannotSeizeAnotherUsersPack
|
||||
// replayed against an empty store, which is exactly what the attacker gets for
|
||||
// free after any wipe.
|
||||
//
|
||||
// The set existing under a name this invocation has only just claimed proves
|
||||
// the set is somebody else's: a real interrupted attempt reserved the name
|
||||
// before creating the set, so it always finds its own reservation waiting.
|
||||
func TestNewPack_WipedStoreCannotAdoptSurvivingPack(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setExists(rb) // the victim's pack outlived our store
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
// Deliberately empty: no reservations, no pack records, nothing.
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, slugTaken) {
|
||||
t.Errorf("reply = %q, want the name-taken refusal", got)
|
||||
}
|
||||
if pack, found := loadPack(t, s); found && !pack.Pending {
|
||||
t.Errorf("adopted a pack that survived the wipe: %+v — /delpack would now destroy its real owner's set", pack)
|
||||
}
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method == "createNewStickerSet" || call.Method == "setStickerSetTitle" {
|
||||
t.Errorf("refused adoption still called %s", call.Method)
|
||||
}
|
||||
}
|
||||
// The refusal must not burn the name either: the set's real owner has to be
|
||||
// able to re-register it after the same wipe.
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
|
||||
t.Error("the refused attempt kept the reservation, denying the name to the set's actual owner")
|
||||
}
|
||||
}
|
||||
|
||||
// The two-command takeover: no crash, no store error, two ordinary /newpack
|
||||
// calls, and the attacker used to end up owning a stranger's pack.
|
||||
//
|
||||
// The first command's GetStickerSet is inconclusive (429, 5xx, a deadline), so
|
||||
// the module correctly keeps the intent and the reservation — re-running is how
|
||||
// a real user recovers. But that turned the attacker's *fresh* reservation into
|
||||
// a *resumed* one, which defeated the per-invocation guard that was supposed to
|
||||
// make adoption safe. The second identical command then adopted.
|
||||
//
|
||||
// The guard is gone; refusing outright is what closes this. The starting state
|
||||
// is an empty store, which is what a restart on the in-memory backend leaves
|
||||
// behind while every pack at Telegram survives.
|
||||
func TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver(t *testing.T) {
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
rb1 := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb1)
|
||||
rb1.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`)
|
||||
if err := s.handleNewPack(ctx, rb1.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil {
|
||||
t.Fatalf("first /newpack: %v", err)
|
||||
}
|
||||
|
||||
// Fresh bot: Reset() deliberately keeps registered failures.
|
||||
rb2 := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb2)
|
||||
setExists(rb2)
|
||||
if err := s.handleNewPack(ctx, rb2.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil {
|
||||
t.Fatalf("second /newpack: %v", err)
|
||||
}
|
||||
|
||||
if pack, found := loadPack(t, s); found && !pack.Pending {
|
||||
t.Errorf("TAKEOVER: caller now owns %+v and can /delpack a set they never created", pack)
|
||||
}
|
||||
if got := rb2.LastSent().Text(); !strings.Contains(got, slugTaken) {
|
||||
t.Errorf("reply = %q, want the name-taken refusal", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending record is not authority to delete a set.
|
||||
//
|
||||
// /newpack writes its intent before Telegram is called, so anyone can produce a
|
||||
// pending record naming any set. DeleteStickerSet is keyed by set name and
|
||||
// Telegram authorises it for every set this bot created — so confirming a
|
||||
// delete from a pending record would let one user destroy another's pack, with
|
||||
// no adoption needed at all.
|
||||
func TestDelPack_PendingRecordDeletesNothingAtTelegram(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
// What a post-wipe /newpack against a stranger's slug leaves behind.
|
||||
seedInterrupted(t, s, "mypack", testSet)
|
||||
|
||||
if err := s.handleDelPack(ctx, rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil {
|
||||
t.Fatalf("handleDelPack: %v", err)
|
||||
}
|
||||
|
||||
if n := countMethod(rb, "deleteStickerSet"); n != 0 {
|
||||
t.Errorf("deleteStickerSet calls = %d, want 0 — an unconfirmed record must not reach Telegram", n)
|
||||
}
|
||||
// It should still clean up locally, so the user is not wedged.
|
||||
if _, found := loadPack(t, s); found {
|
||||
t.Error("local record survived, so /newpack stays blocked")
|
||||
}
|
||||
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
|
||||
t.Error("name stayed reserved with nothing behind it")
|
||||
}
|
||||
}
|
||||
|
||||
// releaseSlug verifies the holder itself rather than trusting its callers.
|
||||
//
|
||||
// A bare delete-by-name is a cross-user primitive: it is reached from seven
|
||||
// call sites, and one of them getting the owner wrong would hand a live name
|
||||
// away while the set still exists.
|
||||
func TestReleaseSlug_RefusesANameHeldBySomeoneElse(t *testing.T) {
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
const holder, caller = int64(1), int64(2)
|
||||
|
||||
if err := s.slugs.Put(ctx, slugKey("mypack"),
|
||||
SlugReservation{Slug: "mypack", OwnerID: holder, CreatedAt: fixedNow.UnixMilli()}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
s.releaseSlug(ctx, caller, "mypack")
|
||||
|
||||
held, found, err := getSlugReservation(ctx, s.slugs, "mypack")
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if !found || held.OwnerID != holder {
|
||||
t.Error("a non-holder released the name; the holder's set is still live and the name is now claimable")
|
||||
}
|
||||
}
|
||||
|
||||
// The release must survive a cancelled request context.
|
||||
//
|
||||
// Both the ownership read and the delete run on a detached context. When only
|
||||
// the delete was detached, a shutdown mid-handler failed the read and returned
|
||||
// early — leaving a reservation with no pack and no set behind it, which no
|
||||
// code path can reach again.
|
||||
func TestReleaseSlug_CompletesOnACancelledContext(t *testing.T) {
|
||||
s := newTestState()
|
||||
seed := context.Background()
|
||||
|
||||
if err := s.slugs.Put(seed, slugKey("mypack"),
|
||||
SlugReservation{Slug: "mypack", OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// The in-memory backend ignores context entirely, so cancelling one proves
|
||||
// nothing against it — this assertion passed whether or not the code
|
||||
// detached until the store was made to honour cancellation.
|
||||
s.slugs = ctxHonouringSlugs{inner: s.slugs}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // as if SIGTERM landed mid-handler
|
||||
|
||||
s.releaseSlug(ctx, testUser, "mypack")
|
||||
|
||||
if _, held, _ := getSlugReservation(seed, s.slugs, "mypack"); held {
|
||||
t.Error("reservation survived a cancelled release; the name is stranded permanently")
|
||||
}
|
||||
}
|
||||
|
||||
// ctxHonouringSlugs makes a SlugStore respect context cancellation, which the
|
||||
// in-memory backend does not. Needed to test anything about detached contexts:
|
||||
// against the bare memory store the operation completes either way.
|
||||
type ctxHonouringSlugs struct{ inner SlugStore }
|
||||
|
||||
func (c ctxHonouringSlugs) Get(ctx context.Context, id string) (SlugReservation, int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return SlugReservation{}, 0, err
|
||||
}
|
||||
return c.inner.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (c ctxHonouringSlugs) Put(ctx context.Context, id string, val SlugReservation) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.inner.Put(ctx, id, val)
|
||||
}
|
||||
|
||||
func (c ctxHonouringSlugs) PutVersioned(ctx context.Context, id string, expectedVersion int64, val SlugReservation) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.inner.PutVersioned(ctx, id, expectedVersion, val)
|
||||
}
|
||||
|
||||
func (c ctxHonouringSlugs) Delete(ctx context.Context, id string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.inner.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (c ctxHonouringSlugs) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.inner.List(ctx, prefix)
|
||||
}
|
||||
|
||||
// A record that goes away takes its outstanding confirmation with it.
|
||||
//
|
||||
// The callback re-checks authority anyway, so this is defence in depth — but an
|
||||
// unpinned guard is how the last one rotted into a blocklist unnoticed.
|
||||
func TestDropPackRecord_ClearsAnOutstandingConfirmation(t *testing.T) {
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
seedPack(t, s, 3)
|
||||
seedPendingDelete(t, s, nil)
|
||||
|
||||
s.dropPackRecord(ctx, testUser)
|
||||
|
||||
if _, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)); err == nil {
|
||||
t.Error("confirmation outlived the record that authorised it")
|
||||
}
|
||||
}
|
||||
|
||||
// Resuming an interrupted attempt must use the title from the command the user
|
||||
// just sent, not the one stored by the attempt that failed.
|
||||
//
|
||||
// The stored record was returned verbatim, so a retyped title was silently
|
||||
// discarded and the success message quoted the old one — "/newpack mypack New"
|
||||
// answering "Created Old."
|
||||
func TestNewPack_ResumeUsesTheTitleJustTyped(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb)
|
||||
setMissing(rb) // nothing was created last time, so this run creates it
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
seedInterrupted(t, s, "mypack", testSet) // stored title is "Old"
|
||||
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack Brand New Title", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
pack, found := loadPack(t, s)
|
||||
if !found {
|
||||
t.Fatal("no record after a resumed create")
|
||||
}
|
||||
if pack.Title != "Brand New Title" {
|
||||
t.Errorf("stored title = %q, want the one just typed", pack.Title)
|
||||
}
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "Brand New Title") {
|
||||
t.Errorf("reply = %q, want it to quote the title just typed", got)
|
||||
}
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method == "createNewStickerSet" && call.Form["title"] != "Brand New Title" {
|
||||
t.Errorf("created with title %q, want the one just typed", call.Form["title"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resuming must not re-derive the set name.
|
||||
//
|
||||
// Pack.Name is built from the bot's username, which can change at BotFather.
|
||||
// The stored name identifies the set the interrupted attempt may already have
|
||||
// created; refreshing it from the current username would repoint the record at
|
||||
// a name nothing exists under, orphaning that set and aiming every later
|
||||
// command at the wrong one. ownsSet documents the same rule.
|
||||
func TestNewPack_ResumeKeepsTheStoredSetName(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
stubBotIdentity(rb) // resolves to "testbot"
|
||||
setMissing(rb)
|
||||
s := newTestState()
|
||||
ctx := context.Background()
|
||||
|
||||
// The earlier attempt ran while the bot was called something else.
|
||||
const legacySet = "mypack_by_oldbot"
|
||||
seedInterrupted(t, s, "mypack", legacySet)
|
||||
|
||||
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
|
||||
t.Fatalf("handleNewPack: %v", err)
|
||||
}
|
||||
|
||||
pack, found := loadPack(t, s)
|
||||
if !found {
|
||||
t.Fatal("no record after resume")
|
||||
}
|
||||
if pack.Name != legacySet {
|
||||
t.Errorf("set name = %q, want the stored %q — the earlier attempt's set is now orphaned", pack.Name, legacySet)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) PackStore {
|
||||
t.Helper()
|
||||
return storage.Typed[Pack](storage.NewMemoryProvider().Collection("sticker"))
|
||||
}
|
||||
|
||||
// Pack is persisted with its fields hoisted to the document root, so a bson tag
|
||||
// colliding with a reserved root field would panic at startup. Typed panics on
|
||||
// collision; constructing the store is the assertion.
|
||||
func TestPack_NoReservedFieldCollision(t *testing.T) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("Pack collides with a reserved storage field: %v", rec)
|
||||
}
|
||||
}()
|
||||
_ = newTestStore(t)
|
||||
}
|
||||
|
||||
// The key is the owner ID alone, which is what makes the lookup itself the
|
||||
// ownership check: there is no key shape that reads another user's pack.
|
||||
func TestGetPack_IsolatesOwners(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
want := Pack{Slug: "alpha", Name: "alpha_by_bot", Title: "Alpha", OwnerID: 1, Count: 3}
|
||||
if err := store.Put(ctx, packKey(1), want); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
got, found, err := getPack(ctx, store, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("getPack(owner 1) = (%+v, %v, %v), want found", got, found, err)
|
||||
}
|
||||
if got.Slug != want.Slug || got.Count != want.Count {
|
||||
t.Errorf("getPack(owner 1) = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
other, found, err := getPack(ctx, store, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("getPack(owner 2) error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Errorf("getPack(owner 2) returned owner 1's pack: %+v", other)
|
||||
}
|
||||
}
|
||||
|
||||
// A user who has never run /newpack is the normal case, not an error worth
|
||||
// propagating to every caller.
|
||||
func TestGetPack_MissingIsNotAnError(t *testing.T) {
|
||||
got, found, err := getPack(context.Background(), newTestStore(t), 404)
|
||||
if err != nil {
|
||||
t.Fatalf("getPack(unknown) error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Errorf("getPack(unknown) found %+v, want not found", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLink(t *testing.T) {
|
||||
if got, want := shareLink("mypack_by_bot"), "https://t.me/addstickers/mypack_by_bot"; got != want {
|
||||
t.Errorf("shareLink = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
// callbackPrefix owns this module's inline-button namespace. Checked
|
||||
// bidirectionally against every other module's prefix at registry build.
|
||||
callbackPrefix = "sticker_pack:"
|
||||
// deleteCallbackPrefix is the confirm button for /delpack.
|
||||
deleteCallbackPrefix = callbackPrefix + "d:"
|
||||
// pendingDeletePrefix namespaces pending actions inside the collection the
|
||||
// Pack records also live in.
|
||||
pendingDeletePrefix = "pending-delete:"
|
||||
|
||||
// pendingDeleteTTL is short on purpose. The stock module uses 24h for a
|
||||
// non-destructive suggestion; deleting a pack is irreversible on Telegram's
|
||||
// side, so the window to confirm is minutes, not a day.
|
||||
pendingDeleteTTL = 10 * time.Minute
|
||||
|
||||
// maxCallbackBytes is Telegram's cap on inline-button callback data.
|
||||
maxCallbackBytes = 64
|
||||
)
|
||||
|
||||
// PendingDeleteStore is the second typed view over the module's collection.
|
||||
type PendingDeleteStore = storage.DocStore[PendingDelete]
|
||||
|
||||
// PendingDelete is the server-side half of a /delpack confirm button.
|
||||
//
|
||||
// The payload in the button is an opaque id and nothing else. Everything that
|
||||
// decides whether a press is legitimate — who, where, which message, until
|
||||
// when — lives here, where the user cannot edit it.
|
||||
type PendingDelete struct {
|
||||
ID string `bson:"id"`
|
||||
OwnerID int64 `bson:"ownerId"`
|
||||
Slug string `bson:"slug"`
|
||||
SetName string `bson:"setName"`
|
||||
ChatID int64 `bson:"chatId"`
|
||||
MessageID int `bson:"messageId"`
|
||||
CreatedAt int64 `bson:"createdAt"`
|
||||
ExpiresAt int64 `bson:"expiresAt"`
|
||||
}
|
||||
|
||||
// pendingDeleteKey is deterministic per user, so running /delpack twice
|
||||
// supersedes the first prompt instead of leaving two independently valid
|
||||
// delete capabilities in scrollback — the shape stock/pending_dividend.go
|
||||
// already uses and documents.
|
||||
//
|
||||
// A random per-invocation key produced two live confirmations at once, which is
|
||||
// worse than untidy: the stale one could be pressed after the pack it named was
|
||||
// already deleted and a *different* pack created, and its "set is gone" result
|
||||
// then cleared the new pack's record.
|
||||
//
|
||||
// It also bounds storage. Pending actions are only deleted when consumed, so a
|
||||
// random key let anyone accumulate documents by running a public command and
|
||||
// never tapping.
|
||||
func pendingDeleteKey(ownerID int64) string {
|
||||
return pendingDeletePrefix + strconv.FormatInt(ownerID, 10)
|
||||
}
|
||||
|
||||
// newActionID returns an unguessable id for a pending action. Guessability
|
||||
// matters: the id is the entire contents of the callback payload.
|
||||
func newActionID() (string, error) {
|
||||
var buf [12]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf[:]), nil
|
||||
}
|
||||
|
||||
// deleteCallbackData builds the button payload — a prefix plus the opaque id,
|
||||
// well inside the 64-byte cap.
|
||||
func deleteCallbackData(id string) string { return deleteCallbackPrefix + id }
|
||||
|
||||
// parseDeleteCallback recovers the action id from client-controlled callback
|
||||
// data. The id is only a lookup key; every authorisation check happens against
|
||||
// the stored action.
|
||||
func parseDeleteCallback(data string) (string, bool) {
|
||||
if len(data) > maxCallbackBytes {
|
||||
return "", false
|
||||
}
|
||||
id, ok := strings.CutPrefix(data, deleteCallbackPrefix)
|
||||
if !ok || id == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, r := range id {
|
||||
if !isHexDigit(r) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func isHexDigit(r rune) bool {
|
||||
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// imageDocumentMimes is the allowlist for a replied document. Anything else is
|
||||
// rejected before a single byte is downloaded.
|
||||
var imageDocumentMimes = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/webp": true,
|
||||
}
|
||||
|
||||
// resolvePhotoSource turns a replied photo or image document into an uploaded
|
||||
// sticker file.
|
||||
//
|
||||
// Raw bytes cannot ride along on AddStickerToSet: the form builder honours
|
||||
// attach:// only for []models.InputSticker, and the single InputSticker in
|
||||
// AddStickerToSetParams falls through to a default that drops the attachment
|
||||
// silently. So the image is uploaded first and the returned file_id is used.
|
||||
func (s *state) resolvePhotoSource(ctx context.Context, b *bot.Bot, ownerID int64, replied *models.Message) (stickerSource, error) {
|
||||
fileID, err := photoFileID(replied)
|
||||
if err != nil {
|
||||
return stickerSource{}, err
|
||||
}
|
||||
|
||||
// Reserve the caller's reply tail: everything from here to the upload is
|
||||
// the slow leg. See mediaContext.
|
||||
mediaCtx, cancelMedia := mediaContext(ctx)
|
||||
defer cancelMedia()
|
||||
|
||||
raw, err := downloadFile(mediaCtx, b, fileID)
|
||||
if err != nil {
|
||||
return stickerSource{}, err
|
||||
}
|
||||
png, err := toStickerPNG(raw)
|
||||
if err != nil {
|
||||
return stickerSource{}, err
|
||||
}
|
||||
|
||||
uploaded, err := b.UploadStickerFile(mediaCtx, &bot.UploadStickerFileParams{
|
||||
UserID: ownerID,
|
||||
Sticker: &models.InputFileUpload{Filename: "sticker.png", Data: bytes.NewReader(png)},
|
||||
StickerFormat: stickerFormatStatic,
|
||||
})
|
||||
if err != nil {
|
||||
return stickerSource{}, err
|
||||
}
|
||||
// Consumed immediately, so the file_id's undocumented validity window never
|
||||
// matters. Do not restructure this into upload-now-use-later.
|
||||
return stickerSource{fileID: uploaded.FileID}, nil
|
||||
}
|
||||
|
||||
// photoFileID picks the file to convert from a replied message.
|
||||
func photoFileID(replied *models.Message) (string, error) {
|
||||
if len(replied.Photo) > 0 {
|
||||
// Pick the largest by size rather than trusting the array's order.
|
||||
best := replied.Photo[0]
|
||||
for _, size := range replied.Photo[1:] {
|
||||
if size.FileSize > best.FileSize {
|
||||
best = size
|
||||
}
|
||||
}
|
||||
if best.FileSize > maxSourceBytes {
|
||||
return "", refuse("That image is too large — keep it under 2 MB.")
|
||||
}
|
||||
return best.FileID, nil
|
||||
}
|
||||
|
||||
if doc := replied.Document; doc != nil {
|
||||
if !imageDocumentMimes[doc.MimeType] {
|
||||
return "", refuse("That file is not a supported image. Send a PNG, JPEG or WEBP.")
|
||||
}
|
||||
if doc.FileSize > maxSourceBytes {
|
||||
return "", refuse("That image is too large — keep it under 2 MB.")
|
||||
}
|
||||
return doc.FileID, nil
|
||||
}
|
||||
|
||||
return "", refuse("Reply to a sticker, photo, or image file with this command.")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// Source selection happens before any download, so an unsupported or oversized
|
||||
// file costs nothing.
|
||||
func TestPhotoFileID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
replied *models.Message
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
// Sizes are picked by FileSize rather than array order.
|
||||
name: "largest photo size wins",
|
||||
replied: &models.Message{Photo: []models.PhotoSize{
|
||||
{FileID: "small", FileSize: 100},
|
||||
{FileID: "large", FileSize: 5000},
|
||||
{FileID: "medium", FileSize: 900},
|
||||
}},
|
||||
want: "large", ok: true,
|
||||
},
|
||||
{
|
||||
name: "png document",
|
||||
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/png"}},
|
||||
want: "doc", ok: true,
|
||||
},
|
||||
{
|
||||
name: "jpeg document",
|
||||
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/jpeg"}},
|
||||
want: "doc", ok: true,
|
||||
},
|
||||
{
|
||||
name: "webp document",
|
||||
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/webp"}},
|
||||
want: "doc", ok: true,
|
||||
},
|
||||
{
|
||||
name: "pdf rejected",
|
||||
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "application/pdf"}},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "gif document rejected",
|
||||
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/gif"}},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "oversized photo rejected",
|
||||
replied: &models.Message{Photo: []models.PhotoSize{{FileID: "huge", FileSize: maxSourceBytes + 1}}},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "oversized document rejected",
|
||||
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/png", FileSize: maxSourceBytes + 1}},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "nothing usable",
|
||||
replied: &models.Message{Text: "hello"},
|
||||
ok: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := photoFileID(tc.replied)
|
||||
if tc.ok {
|
||||
if err != nil {
|
||||
t.Fatalf("photoFileID: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("file_id = %q, want %q", got, tc.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("photoFileID = %q, want a refusal", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// stickerFormatStatic is InputSticker.Format for this module. The whole module
|
||||
// is static-only; animated and video packs are out of scope.
|
||||
const stickerFormatStatic = "static"
|
||||
|
||||
// notOwnedRefusal answers every "that sticker is not yours to manage" case.
|
||||
//
|
||||
// It is deliberately the same sentence whether the caller has no pack at all,
|
||||
// or replied to a sticker from someone else's pack, or from a set this bot did
|
||||
// not create. Distinct wording would answer "does this set belong to another
|
||||
// user of this bot?" for any set the caller can find — a question they have no
|
||||
// standing to ask. The uniformity is the feature; do not "improve" this into
|
||||
// three specific messages.
|
||||
//
|
||||
// /newpack's slug-occupancy answer is a separate, accepted disclosure: a share
|
||||
// link is publicly probeable without the bot, so it reveals nothing new.
|
||||
const notOwnedRefusal = "That sticker is not in your pack. Reply to a sticker from your own pack — /mypack shows it."
|
||||
|
||||
// usageReplyToSticker is the shared "you must reply to something" line.
|
||||
const usageReplyToSticker = "Reply to a sticker with this command."
|
||||
|
||||
// stickerSource is a resolved sticker ready to be added to a set: whatever the
|
||||
// replied message carried, reduced to a file_id.
|
||||
type stickerSource struct {
|
||||
fileID string // usable directly as InputSticker.Sticker
|
||||
emoji []string // inherited from a replied sticker; at most one element
|
||||
}
|
||||
|
||||
// ownedSticker is an existing sticker in the caller's own pack.
|
||||
type ownedSticker struct {
|
||||
fileID string
|
||||
pack Pack
|
||||
}
|
||||
|
||||
// resolveSource turns the replied message into a sticker source.
|
||||
//
|
||||
// It takes ctx, b, and ownerID even though the sticker branch uses none of
|
||||
// them: the photo branch (which resolves by downloading the image and calling
|
||||
// UploadStickerFile) lives in this same function, and declaring the full
|
||||
// signature up front keeps that from churning every call site.
|
||||
func (s *state) resolveSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error) {
|
||||
replied := msg.ReplyToMessage
|
||||
if replied == nil {
|
||||
return stickerSource{}, refuse(usageReplyToSticker)
|
||||
}
|
||||
|
||||
if st := replied.Sticker; st != nil {
|
||||
if err := requireStaticSticker(st); err != nil {
|
||||
return stickerSource{}, err
|
||||
}
|
||||
src := stickerSource{fileID: st.FileID}
|
||||
if st.Emoji != "" {
|
||||
// models.Sticker.Emoji is a single string, so a replied sticker
|
||||
// contributes at most one emoji.
|
||||
src.emoji = []string{st.Emoji}
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
return s.resolvePhotoSource(ctx, b, ownerID, replied)
|
||||
}
|
||||
|
||||
// requireStaticSticker enforces the module's static-only scope on a sticker the
|
||||
// bot is about to copy into a set.
|
||||
//
|
||||
// IsAnimated and IsVideo are the obvious half. Type is the half that is easy to
|
||||
// miss: a mask sticker and a custom-emoji sticker are both static, and both are
|
||||
// invalid in a regular sticker set, so the boolean pair alone would let them
|
||||
// through to fail at the API with an opaque error.
|
||||
func requireStaticSticker(st *models.Sticker) error {
|
||||
if st.IsAnimated || st.IsVideo {
|
||||
return refuse("This module handles static stickers only — that one is animated or video.")
|
||||
}
|
||||
if st.Type != "" && st.Type != "regular" {
|
||||
return refuse("That is a mask or custom-emoji sticker, which cannot go in a regular pack.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveOwned resolves a replied sticker that must already be in the caller's
|
||||
// own pack. It is the single ownership gate for /delsticker, /editsticker,
|
||||
// /ordersticker, and /setpackicon.
|
||||
//
|
||||
// Costs exactly one store Get and a string comparison — no List, no API call.
|
||||
func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, error) {
|
||||
replied := msg.ReplyToMessage
|
||||
if replied == nil || replied.Sticker == nil {
|
||||
return ownedSticker{}, refuse(usageReplyToSticker)
|
||||
}
|
||||
st := replied.Sticker
|
||||
if st.SetName == "" {
|
||||
return ownedSticker{}, refuse("That sticker does not belong to any pack.")
|
||||
}
|
||||
// Before the store read: a malformed reply costs nothing, and this keeps
|
||||
// "rejected before any API call" true by construction.
|
||||
if err := requireStaticSticker(st); err != nil {
|
||||
return ownedSticker{}, err
|
||||
}
|
||||
|
||||
pack, found, err := getPack(ctx, s.store, ownerID)
|
||||
if err != nil {
|
||||
return ownedSticker{}, err
|
||||
}
|
||||
// Both branches answer identically — see notOwnedRefusal.
|
||||
if !found || pack.Pending {
|
||||
return ownedSticker{}, refuse(notOwnedRefusal)
|
||||
}
|
||||
if !ownsSet(pack, st.SetName) {
|
||||
return ownedSticker{}, refuse(notOwnedRefusal)
|
||||
}
|
||||
return ownedSticker{fileID: st.FileID, pack: pack}, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
// resolveOwnedText runs the gate and returns the refusal the user would see.
|
||||
func resolveOwnedText(t *testing.T, s *state, upd *models.Update) string {
|
||||
t.Helper()
|
||||
_, err := s.resolveOwned(context.Background(), upd.Message, testUser)
|
||||
if err == nil {
|
||||
t.Fatal("resolveOwned succeeded; want a refusal")
|
||||
}
|
||||
ue, ok := err.(userError)
|
||||
if !ok {
|
||||
t.Fatalf("err is %T (%v), want userError", err, err)
|
||||
}
|
||||
return ue.msg
|
||||
}
|
||||
|
||||
// The point of the uniform refusal: "you have no pack" and "that set is not
|
||||
// yours" must be indistinguishable, or a user can probe which sets exist under
|
||||
// this bot by elimination.
|
||||
//
|
||||
// This asserts the two replies against *each other* rather than against fixed
|
||||
// strings, so rewording the copy cannot silently reintroduce the disclosure.
|
||||
func TestResolveOwned_RefusalsAreIdentical(t *testing.T) {
|
||||
noPack := newTestState()
|
||||
noPackText := resolveOwnedText(t, noPack, stickerReply("/delsticker", otherSet))
|
||||
|
||||
withPack := newTestState()
|
||||
seedPack(t, withPack, 2)
|
||||
foreignText := resolveOwnedText(t, withPack, stickerReply("/delsticker", otherSet))
|
||||
|
||||
if noPackText != foreignText {
|
||||
t.Errorf("refusals differ and leak whether a set exists:\n no pack: %q\n foreign: %q", noPackText, foreignText)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending record is not a usable pack, and must refuse identically too.
|
||||
func TestResolveOwned_PendingRefusesIdentically(t *testing.T) {
|
||||
noPack := newTestState()
|
||||
noPackText := resolveOwnedText(t, noPack, stickerReply("/delsticker", otherSet))
|
||||
|
||||
pendingState := newTestState()
|
||||
pack := seedPack(t, pendingState, 0)
|
||||
pack.Pending = true
|
||||
if err := pendingState.store.Put(context.Background(), packKey(testUser), pack); err != nil {
|
||||
t.Fatalf("seed pending: %v", err)
|
||||
}
|
||||
pendingText := resolveOwnedText(t, pendingState, stickerReply("/delsticker", testSet))
|
||||
|
||||
if noPackText != pendingText {
|
||||
t.Errorf("pending refusal differs:\n no pack: %q\n pending: %q", noPackText, pendingText)
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram echoes SetName with the casing the set was created with, so
|
||||
// ownership has to fold case or a user's own pack stops resolving.
|
||||
func TestResolveOwned_CaseInsensitiveSetName(t *testing.T) {
|
||||
s := newTestState()
|
||||
seedPack(t, s, 1)
|
||||
|
||||
owned, err := s.resolveOwned(context.Background(), stickerReply("/delsticker", "MyPack_By_TestBot").Message, testUser)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveOwned with differing case: %v", err)
|
||||
}
|
||||
if owned.pack.Name != testSet {
|
||||
t.Errorf("pack.Name = %q, want %q", owned.pack.Name, testSet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOwned_UsageErrors(t *testing.T) {
|
||||
s := newTestState()
|
||||
seedPack(t, s, 1)
|
||||
|
||||
t.Run("no reply", func(t *testing.T) {
|
||||
upd := testutil.NewPrivateMessage(testUser, "/delsticker")
|
||||
if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil {
|
||||
t.Error("resolveOwned with no reply succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply is not a sticker", func(t *testing.T) {
|
||||
upd := testutil.NewPrivateMessage(testUser, "/delsticker")
|
||||
upd.Message.ReplyToMessage = &models.Message{Text: "hello"}
|
||||
if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil {
|
||||
t.Error("resolveOwned with a text reply succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sticker has no set", func(t *testing.T) {
|
||||
upd := stickerReply("/delsticker", testSet)
|
||||
upd.Message.ReplyToMessage.Sticker.SetName = ""
|
||||
if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil {
|
||||
t.Error("resolveOwned with an empty set_name succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The static-only gate. IsAnimated/IsVideo are the obvious half; Type is the
|
||||
// half a boolean-only check misses — a mask sticker is static yet invalid in a
|
||||
// regular set.
|
||||
func TestRequireStaticSticker(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sticker models.Sticker
|
||||
ok bool
|
||||
}{
|
||||
{"regular", models.Sticker{Type: "regular"}, true},
|
||||
{"type absent", models.Sticker{}, true},
|
||||
{"animated", models.Sticker{Type: "regular", IsAnimated: true}, false},
|
||||
{"video", models.Sticker{Type: "regular", IsVideo: true}, false},
|
||||
{"mask", models.Sticker{Type: "mask"}, false},
|
||||
{"custom emoji", models.Sticker{Type: "custom_emoji"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := requireStaticSticker(&tc.sticker)
|
||||
if tc.ok && err != nil {
|
||||
t.Errorf("requireStaticSticker = %v, want nil", err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Error("requireStaticSticker = nil, want a refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Both entry points must enforce it: resolveSource is the one that can actually
|
||||
// receive a non-static sticker, and neither may reach an API call.
|
||||
func TestStaticGate_BlocksBothPathsBeforeAnyAPICall(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(*models.Sticker)
|
||||
setName string
|
||||
}{
|
||||
{"animated source", func(st *models.Sticker) { st.IsAnimated = true }, otherSet},
|
||||
{"video source", func(st *models.Sticker) { st.IsVideo = true }, otherSet},
|
||||
{"mask source", func(st *models.Sticker) { st.Type = "mask" }, otherSet},
|
||||
{"animated owned", func(st *models.Sticker) { st.IsAnimated = true }, testSet},
|
||||
{"mask owned", func(st *models.Sticker) { st.Type = "mask" }, testSet},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
seedPack(t, s, 1)
|
||||
|
||||
upd := stickerReply("/addsticker", tc.setName)
|
||||
tc.mutate(upd.Message.ReplyToMessage.Sticker)
|
||||
if err := s.handleAddSticker(context.Background(), rb.Bot, upd); err != nil {
|
||||
t.Fatalf("handleAddSticker: %v", err)
|
||||
}
|
||||
if countMethod(rb, "addStickerToSet") != 0 {
|
||||
t.Errorf("methods = %v, want no API call", methodsSent(rb))
|
||||
}
|
||||
|
||||
rb2 := testutil.NewRecordingBot(t)
|
||||
if err := s.handleDelSticker(context.Background(), rb2.Bot, upd); err != nil {
|
||||
t.Fatalf("handleDelSticker: %v", err)
|
||||
}
|
||||
if countMethod(rb2, "deleteStickerFromSet") != 0 {
|
||||
t.Errorf("methods = %v, want no API call", methodsSent(rb2))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A replied sticker contributes at most one emoji, because models.Sticker.Emoji
|
||||
// is a single string.
|
||||
func TestResolveSource_InheritsSingleEmoji(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
s := newTestState()
|
||||
|
||||
src, err := s.resolveSource(context.Background(), rb.Bot, testUser, stickerReply("/addsticker", otherSet).Message)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSource: %v", err)
|
||||
}
|
||||
if len(src.emoji) != 1 || src.emoji[0] != "🎉" {
|
||||
t.Errorf("emoji = %q, want exactly one 🎉", src.emoji)
|
||||
}
|
||||
if src.fileID == "" {
|
||||
t.Error("fileID is empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// errNoPersonalSender is returned when a message carries no usable personal
|
||||
// identity. Handlers turn it into senderRefusal.
|
||||
var errNoPersonalSender = errors.New("sticker: no personal sender")
|
||||
|
||||
// senderRefusal explains the fix rather than only denying. Anonymous posting is
|
||||
// a per-message toggle, so the user can act on this immediately.
|
||||
const senderRefusal = "Sticker packs need a personal account. Turn off anonymous posting for this message and try again."
|
||||
|
||||
// senderID returns the personal Telegram user behind msg.
|
||||
//
|
||||
// Every pack is keyed by this value, so it must identify one human. Telegram
|
||||
// substitutes a single global GroupAnonymousBot user for *every* anonymous
|
||||
// group-admin message and puts the real origin in SenderChat: without the
|
||||
// SenderChat check, all anonymous admins across all groups would share one
|
||||
// pack. Under one-pack-per-user that is worse than a leak — the first anonymous
|
||||
// admin to run /newpack would own the result and block every other one.
|
||||
//
|
||||
// Other modules check only From != nil && From.ID != 0, which is safe for
|
||||
// paper-trading state but not for durable Telegram-side objects.
|
||||
func senderID(msg *models.Message) (int64, error) {
|
||||
if msg == nil || msg.From == nil || msg.From.ID == 0 {
|
||||
return 0, errNoPersonalSender
|
||||
}
|
||||
if msg.From.IsBot {
|
||||
return 0, errNoPersonalSender
|
||||
}
|
||||
if msg.SenderChat != nil {
|
||||
return 0, errNoPersonalSender
|
||||
}
|
||||
return msg.From.ID, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// Every pack is keyed by this value, so anything that is not one human must be
|
||||
// refused before the key is built.
|
||||
func TestSenderID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg *models.Message
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"personal user", &models.Message{From: &models.User{ID: 42}}, 42, true},
|
||||
{"nil message", nil, 0, false},
|
||||
{"nil from", &models.Message{}, 0, false},
|
||||
{"zero id", &models.Message{From: &models.User{ID: 0}}, 0, false},
|
||||
{"bot sender", &models.Message{From: &models.User{ID: 7, IsBot: true}}, 0, false},
|
||||
{
|
||||
// Telegram substitutes one global GroupAnonymousBot user for every
|
||||
// anonymous admin message; without this check they would all share
|
||||
// a single pack.
|
||||
"anonymous admin",
|
||||
&models.Message{From: &models.User{ID: 1087968824, IsBot: true}, SenderChat: &models.Chat{ID: -100}},
|
||||
0, false,
|
||||
},
|
||||
{
|
||||
// A channel post carries SenderChat with a non-bot From.
|
||||
"sender chat present",
|
||||
&models.Message{From: &models.User{ID: 42}, SenderChat: &models.Chat{ID: -100}},
|
||||
0, false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := senderID(tc.msg)
|
||||
if tc.ok {
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("senderID = (%d, %v), want (%d, nil)", got, err, tc.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("senderID = (%d, nil), want an error", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Denying without explaining leaves the user with no move; anonymous posting is
|
||||
// a per-message toggle they can flip immediately.
|
||||
func TestSenderRefusal_ExplainsTheFix(t *testing.T) {
|
||||
for _, want := range []string{"personal account", "anonymous"} {
|
||||
if !strings.Contains(senderRefusal, want) {
|
||||
t.Errorf("senderRefusal %q does not mention %q", senderRefusal, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxSetNameLen is Telegram's cap on a sticker set's short name.
|
||||
maxSetNameLen = 64
|
||||
// maxTitleLen is Telegram's cap on a set title.
|
||||
maxTitleLen = 64
|
||||
// minSlugLen / maxSlugLen keep the share link readable and leave room for
|
||||
// the "_by_<botusername>" suffix inside maxSetNameLen.
|
||||
minSlugLen = 3
|
||||
maxSlugLen = 40
|
||||
)
|
||||
|
||||
// slugRe is the user-chosen half of a set name: 3-40 chars, starting with a
|
||||
// letter. Telegram additionally forbids consecutive underscores, which a
|
||||
// character class cannot express, so validateSlug checks that separately.
|
||||
var slugRe = regexp.MustCompile(`^[a-z][a-z0-9_]{2,39}$`)
|
||||
|
||||
// validateSlug reports why a slug is unusable, or nil when it is fine.
|
||||
//
|
||||
// The slug is the one irreversible choice in this module: it fixes
|
||||
// t.me/addstickers/<slug>_by_<bot> forever, because Telegram has no
|
||||
// rename-short-name method. Rejecting loudly here is much cheaper than a user
|
||||
// discovering the typo is permanent.
|
||||
func validateSlug(slug string) error {
|
||||
if !slugRe.MatchString(slug) {
|
||||
return refuse(fmt.Sprintf("Pack name must be %d-%d characters: lowercase letters, digits and underscores, starting with a letter.", minSlugLen, maxSlugLen))
|
||||
}
|
||||
if strings.Contains(slug, "__") {
|
||||
return refuse("Pack name cannot contain two underscores in a row.")
|
||||
}
|
||||
if strings.HasSuffix(slug, "_") {
|
||||
return refuse("Pack name cannot end with an underscore.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeSetName builds the Telegram set name for a new pack. It is used only at
|
||||
// creation — never to resolve ownership, which compares the *stored* name (see
|
||||
// ownsSet).
|
||||
//
|
||||
// The error reports the remaining budget rather than only refusing, because the
|
||||
// only fix available to the user is a shorter slug and the limit depends on the
|
||||
// bot's username length, which they cannot see.
|
||||
func makeSetName(slug, botUsername string) (string, error) {
|
||||
if botUsername == "" {
|
||||
return "", errNoUsername
|
||||
}
|
||||
suffix := "_by_" + botUsername
|
||||
if len(slug)+len(suffix) > maxSetNameLen {
|
||||
budget := maxSetNameLen - len(suffix)
|
||||
if budget > maxSlugLen {
|
||||
budget = maxSlugLen
|
||||
}
|
||||
return "", refuse(fmt.Sprintf("Pack name is too long for this bot — use at most %d characters.", budget))
|
||||
}
|
||||
return slug + suffix, nil
|
||||
}
|
||||
|
||||
// ownsSet reports whether setName is the caller's pack, comparing
|
||||
// case-insensitively against the *stored* Pack.Name.
|
||||
//
|
||||
// It deliberately does not re-derive the name from the live bot username.
|
||||
// Renaming the bot in BotFather is supported and leaves existing set names
|
||||
// untouched, so a derived comparison would make every user's own pack refuse as
|
||||
// "not yours" while /mypack still displayed it. Comparing the stored name also
|
||||
// sidesteps casing: Telegram returns SetName with whatever casing the set was
|
||||
// created with.
|
||||
func ownsSet(pack Pack, setName string) bool {
|
||||
if pack.Name == "" || setName == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(pack.Name, setName)
|
||||
}
|
||||
|
||||
// usernameResolver caches the bot's username for building new set names.
|
||||
//
|
||||
// The bot starts with bot.WithSkipGetMe(), so nothing populates a username
|
||||
// until this asks. Failures are never cached: a transient GetMe error must not
|
||||
// disable /newpack for the process's lifetime.
|
||||
type usernameResolver struct {
|
||||
mu sync.Mutex
|
||||
username string
|
||||
}
|
||||
|
||||
// resolve returns the bot's username, calling GetMe at most once per success.
|
||||
// It takes the handler's *bot.Bot rather than Deps.Bot, which is documented
|
||||
// nil-safe and is nil under BuildOptions{}.
|
||||
func (r *usernameResolver) resolve(ctx context.Context, b *bot.Bot) (string, error) {
|
||||
r.mu.Lock()
|
||||
cached := r.username
|
||||
r.mu.Unlock()
|
||||
if cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
me, err := b.GetMe(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if me == nil || me.Username == "" {
|
||||
return "", errNoUsername
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.username = me.Username
|
||||
r.mu.Unlock()
|
||||
return me.Username, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateSlug(t *testing.T) {
|
||||
cases := []struct {
|
||||
slug string
|
||||
ok bool
|
||||
}{
|
||||
{"mypack", true},
|
||||
{"my_pack_2", true},
|
||||
{"abc", true},
|
||||
{strings.Repeat("a", maxSlugLen), true},
|
||||
{"ab", false}, // too short
|
||||
{strings.Repeat("a", maxSlugLen+1), false}, // too long
|
||||
{"1pack", false}, // leading digit
|
||||
{"My_Pack", false}, // uppercase
|
||||
{"my__pack", false}, // consecutive underscores
|
||||
{"mypack_", false}, // trailing underscore
|
||||
{"my-pack", false}, // hyphen
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := validateSlug(tc.slug)
|
||||
if tc.ok && err != nil {
|
||||
t.Errorf("validateSlug(%q) = %v, want nil", tc.slug, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Errorf("validateSlug(%q) = nil, want an error", tc.slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeSetName(t *testing.T) {
|
||||
got, err := makeSetName("mypack", "miti99bot")
|
||||
if err != nil {
|
||||
t.Fatalf("makeSetName: %v", err)
|
||||
}
|
||||
if want := "mypack_by_miti99bot"; got != want {
|
||||
t.Errorf("makeSetName = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The set name has a hard 64-char ceiling and the slug is the only part the
|
||||
// user controls, so the refusal has to name the budget they actually have.
|
||||
func TestMakeSetName_TooLongReportsBudget(t *testing.T) {
|
||||
username := strings.Repeat("b", 30)
|
||||
slug := strings.Repeat("a", maxSlugLen)
|
||||
_, err := makeSetName(slug, username)
|
||||
if err == nil {
|
||||
t.Fatalf("makeSetName(%d-char slug, %d-char username) succeeded; want a refusal", len(slug), len(username))
|
||||
}
|
||||
// 64 - len("_by_" + username) = 30
|
||||
if !strings.Contains(err.Error(), "30") {
|
||||
t.Errorf("refusal %q does not state the remaining budget", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnsSet(t *testing.T) {
|
||||
pack := Pack{Slug: "mypack", Name: "mypack_by_bot"}
|
||||
cases := []struct {
|
||||
setName string
|
||||
want bool
|
||||
}{
|
||||
{"mypack_by_bot", true},
|
||||
{"MyPack_By_Bot", true}, // Telegram echoes the creation casing
|
||||
{"otherpack_by_bot", false},
|
||||
{"mypack_by_otherbot", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ownsSet(pack, tc.setName); got != tc.want {
|
||||
t.Errorf("ownsSet(%q) = %v, want %v", tc.setName, got, tc.want)
|
||||
}
|
||||
}
|
||||
if ownsSet(Pack{}, "anything") {
|
||||
t.Error("ownsSet with an empty stored name = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming the bot in BotFather leaves existing set names untouched. Ownership
|
||||
// compares the stored name for exactly this reason: deriving it from the live
|
||||
// username would make every user's own pack refuse as "not yours".
|
||||
func TestOwnsSet_SurvivesBotRename(t *testing.T) {
|
||||
pack := Pack{Slug: "mypack", Name: "mypack_by_oldbot"}
|
||||
if !ownsSet(pack, "mypack_by_oldbot") {
|
||||
t.Error("pack stopped resolving after the bot was renamed")
|
||||
}
|
||||
// The new username only ever builds *new* names.
|
||||
fresh, err := makeSetName("newpack", "newbot")
|
||||
if err != nil || fresh != "newpack_by_newbot" {
|
||||
t.Errorf("makeSetName after rename = (%q, %v)", fresh, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// handleSetPackIcon sets the pack thumbnail from a sticker already in the pack.
|
||||
//
|
||||
// The sticker's own file_id cannot simply be handed to Telegram: a pack
|
||||
// thumbnail must be exactly 100x100, which a 512px sticker is not. So the image
|
||||
// is fetched, resized, and uploaded as a new file.
|
||||
func (s *state) handleSetPackIcon(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
ctx, cancel := handlerContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
msg := update.Message
|
||||
ownerID, err := senderID(msg)
|
||||
if err != nil {
|
||||
return reply(ctx, b, msg, senderRefusal)
|
||||
}
|
||||
|
||||
owned, err := s.resolveOwned(ctx, msg, ownerID)
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_setpackicon_resolve", err)
|
||||
}
|
||||
|
||||
// Reserve the reply tail before the slow leg. See mediaContext.
|
||||
mediaCtx, cancelMedia := mediaContext(ctx)
|
||||
defer cancelMedia()
|
||||
|
||||
raw, err := downloadFile(mediaCtx, b, owned.fileID)
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_setpackicon_download", err)
|
||||
}
|
||||
thumb, err := toThumbnailPNG(raw)
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_setpackicon_resize", err)
|
||||
}
|
||||
|
||||
if _, err := b.SetStickerSetThumbnail(ctx, &bot.SetStickerSetThumbnailParams{
|
||||
Name: owned.pack.Name,
|
||||
UserID: ownerID,
|
||||
Thumbnail: &models.InputFileUpload{Filename: "thumb.png", Data: bytes.NewReader(thumb)},
|
||||
Format: stickerFormatStatic,
|
||||
}); err != nil {
|
||||
if isStickerSetMissing(err) {
|
||||
s.dropPackRecord(ctx, ownerID)
|
||||
}
|
||||
return replyAPIError(ctx, b, msg, "sticker_setpackicon", err)
|
||||
}
|
||||
return reply(ctx, b, msg, "Pack icon updated.")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
const (
|
||||
// handlerTimeout bounds every handler in this module.
|
||||
//
|
||||
// Nothing else does. The bot registers handlers with
|
||||
// bot.WithNotAsyncHandlers() and one worker, so updates run inline on the
|
||||
// polling goroutine, and the handler context is rootCtx, which carries no
|
||||
// deadline — the only remaining ceiling is the library's shared 60s HTTP
|
||||
// client, per call. A handler making several sequential API calls could
|
||||
// therefore freeze the bot for every user for minutes.
|
||||
handlerTimeout = 10 * time.Second
|
||||
|
||||
// commitTimeout bounds a post-success store write. These run on a context
|
||||
// detached from the request (see commitContext), so they need their own.
|
||||
commitTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// state holds everything the handlers share. Mirrors the shape used by coin
|
||||
// and stock: a typed store, a second typed view for pending actions, the
|
||||
// per-user lock map, and an injectable clock.
|
||||
type state struct {
|
||||
store PackStore
|
||||
pending PendingDeleteStore
|
||||
slugs SlugStore
|
||||
resolver usernameResolver
|
||||
locks keylock.Map
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
func (s *state) now() time.Time {
|
||||
if s.nowFn != nil {
|
||||
return s.nowFn()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// commitContext detaches a store write from the request context.
|
||||
//
|
||||
// rootCtx is cancelled by SIGTERM, and a handler is most likely to be mid-flight
|
||||
// exactly when a deploy lands. A commit that records a completed Telegram-side
|
||||
// action must not be lost because the process is shutting down: at that point
|
||||
// the set already exists and only the bot's memory of it is at stake.
|
||||
func commitContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), commitTimeout)
|
||||
}
|
||||
|
||||
// handlerContext applies the module-wide deadline.
|
||||
func handlerContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, handlerTimeout)
|
||||
}
|
||||
|
||||
// mediaContext bounds the download-and-upload leg of a handler, reserving the
|
||||
// tail of the parent's budget for what comes after it.
|
||||
//
|
||||
// This module is the only one that spends most of its deadline before it has
|
||||
// anything to say: a photo /newpack downloads, resamples and re-uploads before
|
||||
// it calls CreateNewStickerSet. Run on the bare handler context, a slow link
|
||||
// exhausted the whole 10s inside the media leg, and the reply — including the
|
||||
// error reply explaining what went wrong — was then sent on a dead context, so
|
||||
// the user saw nothing at all. chathelper.FetchContext is the existing fix for
|
||||
// exactly this, already used by coin, gold, stock and monkeyd.
|
||||
func mediaContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return chathelper.FetchContext(ctx)
|
||||
}
|
||||
|
||||
// lockUser serialises a user's mutations.
|
||||
//
|
||||
// Nothing contends for it today: the map is state-local to this module, the bot
|
||||
// dispatches inline with a single worker, and this module registers neither a
|
||||
// cron nor a command hook. An earlier version of this comment claimed the cron
|
||||
// scheduler and stats hook contended here — they do not, and a wrong reason for
|
||||
// a right guard is worse than none, because the next reader trusts it.
|
||||
//
|
||||
// It stays because every mutation here is a read-modify-write, which is wrong
|
||||
// the moment dispatch stops being serial, and an uncontended mutex costs
|
||||
// nothing. Note that releaseSlug's read-then-delete is not atomic under this
|
||||
// lock either; that is safe only while dispatch is serial.
|
||||
func (s *state) lockUser(ownerID int64) func() {
|
||||
return s.locks.Acquire(strconv.FormatInt(ownerID, 10))
|
||||
}
|
||||
|
||||
// commandArgs returns the whitespace-separated arguments after the command.
|
||||
func commandArgs(msg *models.Message) []string {
|
||||
return strings.Fields(chathelper.ArgAfterCommand(msg.Text))
|
||||
}
|
||||
|
||||
// commandArgText returns the raw text after the command, trimmed.
|
||||
func commandArgText(msg *models.Message) string {
|
||||
return chathelper.ArgAfterCommand(msg.Text)
|
||||
}
|
||||
|
||||
// reply sends text as a reply to msg.
|
||||
func reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error {
|
||||
return chathelper.Reply(ctx, b, msg, text)
|
||||
}
|
||||
|
||||
// replyErr turns a handler error into a reply.
|
||||
//
|
||||
// A userError is shown verbatim — it was written for the user. Anything else is
|
||||
// logged and replaced with a generic line: internal errors can carry a download
|
||||
// URL with the bot token in it, and this module must never echo one.
|
||||
func replyErr(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error {
|
||||
var ue userError
|
||||
if errors.As(err, &ue) {
|
||||
return reply(ctx, b, msg, ue.msg)
|
||||
}
|
||||
log.Error(op, "err", err)
|
||||
return reply(ctx, b, msg, genericFailure)
|
||||
}
|
||||
|
||||
const genericFailure = "Something went wrong. Try again in a moment."
|
||||
@@ -0,0 +1,89 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
// New is the sticker-packs module factory.
|
||||
//
|
||||
// Commands are unprefixed so they match the names @Stickers uses: the registry
|
||||
// keys commands by Command.Name independent of module name, which is how misc
|
||||
// ships /ff. Three typed views share one collection, with disjoint key spaces:
|
||||
// Pack records keyed by owner ID, name reservations under "slug:", and pending
|
||||
// deletes under "pending-delete:".
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := &state{
|
||||
store: storage.Typed[Pack](deps.Store),
|
||||
pending: storage.Typed[PendingDelete](deps.Store),
|
||||
slugs: storage.Typed[SlugReservation](deps.Store),
|
||||
}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "newpack",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Create your sticker pack from a replied sticker",
|
||||
Parameters: "<pack> <title...>",
|
||||
Handler: s.handleNewPack,
|
||||
},
|
||||
{
|
||||
Name: "mypack",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your sticker pack and its link",
|
||||
Handler: s.handleMyPack,
|
||||
},
|
||||
{
|
||||
Name: "addsticker",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Add the replied sticker to your pack",
|
||||
Parameters: "[emoji...]",
|
||||
Handler: s.handleAddSticker,
|
||||
},
|
||||
{
|
||||
Name: "delsticker",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Remove the replied sticker from your pack",
|
||||
Handler: s.handleDelSticker,
|
||||
},
|
||||
{
|
||||
Name: "editsticker",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Change the emoji of a sticker in your pack",
|
||||
Parameters: "<emoji...>",
|
||||
Handler: s.handleEditSticker,
|
||||
},
|
||||
{
|
||||
Name: "ordersticker",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Move a sticker in your pack to a position",
|
||||
Parameters: "<position>",
|
||||
Handler: s.handleOrderSticker,
|
||||
},
|
||||
{
|
||||
Name: "setpackicon",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Set your pack's icon from a sticker in it",
|
||||
Handler: s.handleSetPackIcon,
|
||||
},
|
||||
{
|
||||
Name: "renamepack",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Change your pack's title (the link cannot change)",
|
||||
Parameters: "<title...>",
|
||||
Handler: s.handleRenamePack,
|
||||
},
|
||||
{
|
||||
Name: "delpack",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Delete your pack after confirmation",
|
||||
Handler: s.handleDelPack,
|
||||
},
|
||||
},
|
||||
Callbacks: []modules.Callback{{
|
||||
Prefix: callbackPrefix,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Handler: s.handleDelPackCallback,
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
)
|
||||
|
||||
// maxStickersPerPack is Telegram's documented ceiling for a regular set. It is
|
||||
// not enforced locally — the server is the authority and a local copy would go
|
||||
// stale — but it is quoted back to the user when the server refuses.
|
||||
const maxStickersPerPack = 120
|
||||
|
||||
// handleAddSticker adds the replied sticker (or, from Phase 5, photo) to the
|
||||
// caller's pack.
|
||||
func (s *state) handleAddSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
ctx, cancel := handlerContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
msg := update.Message
|
||||
ownerID, err := senderID(msg)
|
||||
if err != nil {
|
||||
return reply(ctx, b, msg, senderRefusal)
|
||||
}
|
||||
|
||||
// Every argument is an emoji: with no pack token to disambiguate, a stray
|
||||
// word fails here rather than being silently read as something else.
|
||||
emoji, err := parseEmoji(commandArgs(msg))
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_addsticker_emoji", err)
|
||||
}
|
||||
|
||||
pack, found, err := getPack(ctx, s.store, ownerID)
|
||||
if err != nil {
|
||||
log.Error("sticker_addsticker_load", "err", err)
|
||||
return reply(ctx, b, msg, genericFailure)
|
||||
}
|
||||
// Unlike resolveOwned's deliberately uniform refusal, this one is specific:
|
||||
// it answers only "do *you* have a pack", about the caller's own state, and
|
||||
// so discloses nothing about anyone else.
|
||||
if !found {
|
||||
return reply(ctx, b, msg, noPackYet)
|
||||
}
|
||||
if pack.Pending {
|
||||
return reply(ctx, b, msg, noPackYet+pendingMarker)
|
||||
}
|
||||
|
||||
source, err := s.resolveSource(ctx, b, ownerID, msg)
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_addsticker_source", err)
|
||||
}
|
||||
|
||||
// Precedence: explicit args, then the replied sticker's own emoji, then the
|
||||
// default. Telegram requires at least one.
|
||||
if len(emoji) == 0 {
|
||||
emoji = source.emoji
|
||||
}
|
||||
if len(emoji) == 0 {
|
||||
emoji = []string{defaultEmoji}
|
||||
}
|
||||
|
||||
defer s.lockUser(ownerID)()
|
||||
|
||||
_, err = b.AddStickerToSet(ctx, &bot.AddStickerToSetParams{
|
||||
UserID: ownerID, // always the caller: a non-owner never reaches this call
|
||||
Name: pack.Name,
|
||||
Sticker: models.InputSticker{
|
||||
Sticker: source.fileID,
|
||||
Format: stickerFormatStatic,
|
||||
EmojiList: emoji,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if isStickerSetMissing(err) {
|
||||
s.dropPackRecord(ctx, ownerID)
|
||||
}
|
||||
return replyAPIError(ctx, b, msg, "sticker_addsticker", err)
|
||||
}
|
||||
|
||||
updated, err := s.adjustCount(ctx, ownerID, +1)
|
||||
if err != nil {
|
||||
// The sticker is already in the set; only our count is stale.
|
||||
log.Error("sticker_addsticker_commit", "err", err)
|
||||
updated = pack
|
||||
updated.Count++
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("Added to %s (%d stickers).\n%s",
|
||||
updated.Title, updated.Count, shareLink(updated.Name)))
|
||||
}
|
||||
|
||||
// handleDelSticker removes the replied sticker from the caller's pack.
|
||||
//
|
||||
// It deliberately does *not* probe afterwards to see whether the set survived.
|
||||
// Whether removing the last sticker also destroys the set is undocumented, and
|
||||
// an earlier design that probed would have deleted the pack record whenever the
|
||||
// probe merely failed — so a 429, a DNS blip, or a SIGTERM during a routine
|
||||
// delete would erase the only record of a live pack. Deleting the record needs
|
||||
// a positive signal, and the next command's STICKERSET_INVALID is one.
|
||||
func (s *state) handleDelSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
ctx, cancel := handlerContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
msg := update.Message
|
||||
ownerID, err := senderID(msg)
|
||||
if err != nil {
|
||||
return reply(ctx, b, msg, senderRefusal)
|
||||
}
|
||||
|
||||
owned, err := s.resolveOwned(ctx, msg, ownerID)
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_delsticker_resolve", err)
|
||||
}
|
||||
|
||||
// Same read-modify-write on Count as /addsticker, so the same lock.
|
||||
defer s.lockUser(ownerID)()
|
||||
|
||||
if _, err := b.DeleteStickerFromSet(ctx, &bot.DeleteStickerFromSetParams{Sticker: owned.fileID}); err != nil {
|
||||
if isStickerSetMissing(err) {
|
||||
s.dropPackRecord(ctx, ownerID)
|
||||
}
|
||||
return replyAPIError(ctx, b, msg, "sticker_delsticker", err)
|
||||
}
|
||||
|
||||
pack, err := s.adjustCount(ctx, ownerID, -1)
|
||||
if err != nil {
|
||||
// The sticker is already gone from the set; only our count is stale.
|
||||
log.Error("sticker_delsticker_commit", "err", err)
|
||||
pack = owned.pack
|
||||
if pack.Count > 0 {
|
||||
pack.Count--
|
||||
}
|
||||
}
|
||||
|
||||
if pack.Count == 0 {
|
||||
// Telegram may have removed the now-empty set. /mypack makes no API
|
||||
// calls so it cannot notice, and /newpack stays blocked while a record
|
||||
// exists — so name the command that clears it.
|
||||
return reply(ctx, b, msg, "Removed. Your pack is now empty, and Telegram may have deleted it. "+
|
||||
"If /addsticker says the pack is gone, use /delpack to clear it and /newpack to start again.")
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("Removed. %s now has %d sticker(s).", pack.Title, pack.Count))
|
||||
}
|
||||
|
||||
// handleEditSticker replaces the emoji of a sticker in the caller's pack.
|
||||
func (s *state) handleEditSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
ctx, cancel := handlerContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
msg := update.Message
|
||||
ownerID, err := senderID(msg)
|
||||
if err != nil {
|
||||
return reply(ctx, b, msg, senderRefusal)
|
||||
}
|
||||
|
||||
emoji, err := parseEmoji(commandArgs(msg))
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_editsticker_emoji", err)
|
||||
}
|
||||
// An empty emoji_list is invalid, so this cannot fall back to a default the
|
||||
// way /addsticker does: the user has to say what they want.
|
||||
if len(emoji) == 0 {
|
||||
return reply(ctx, b, msg, "Usage: /editsticker <emoji...>\nReply to a sticker in your pack with at least one emoji.")
|
||||
}
|
||||
|
||||
owned, err := s.resolveOwned(ctx, msg, ownerID)
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_editsticker_resolve", err)
|
||||
}
|
||||
|
||||
if _, err := b.SetStickerEmojiList(ctx, &bot.SetStickerEmojiListParams{
|
||||
Sticker: owned.fileID,
|
||||
EmojiList: emoji,
|
||||
}); err != nil {
|
||||
if isStickerSetMissing(err) {
|
||||
s.dropPackRecord(ctx, ownerID)
|
||||
}
|
||||
return replyAPIError(ctx, b, msg, "sticker_editsticker", err)
|
||||
}
|
||||
return reply(ctx, b, msg, "Emoji updated.")
|
||||
}
|
||||
|
||||
// handleOrderSticker moves a sticker to a new position in the caller's pack.
|
||||
func (s *state) handleOrderSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
ctx, cancel := handlerContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
msg := update.Message
|
||||
ownerID, err := senderID(msg)
|
||||
if err != nil {
|
||||
return reply(ctx, b, msg, senderRefusal)
|
||||
}
|
||||
|
||||
args := commandArgs(msg)
|
||||
if len(args) != 1 {
|
||||
return reply(ctx, b, msg, "Usage: /ordersticker <position>\nReply to a sticker in your pack. Positions start at 0.")
|
||||
}
|
||||
pos, err := strconv.Atoi(args[0])
|
||||
if err != nil || pos < 0 {
|
||||
// Only the lower bound is checked locally. The upper bound is the set's
|
||||
// current size, which Telegram knows and a local copy would not.
|
||||
return reply(ctx, b, msg, "Position must be a whole number, 0 or greater.")
|
||||
}
|
||||
|
||||
owned, err := s.resolveOwned(ctx, msg, ownerID)
|
||||
if err != nil {
|
||||
return replyErr(ctx, b, msg, "sticker_ordersticker_resolve", err)
|
||||
}
|
||||
|
||||
if _, err := b.SetStickerPositionInSet(ctx, &bot.SetStickerPositionInSetParams{
|
||||
Sticker: owned.fileID,
|
||||
Position: pos,
|
||||
}); err != nil {
|
||||
if isStickerSetMissing(err) {
|
||||
s.dropPackRecord(ctx, ownerID)
|
||||
}
|
||||
return replyAPIError(ctx, b, msg, "sticker_ordersticker", err)
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("Moved to position %d.", pos))
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import "testing"
|
||||
|
||||
func TestNormalizeWord(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"crane": "crane",
|
||||
"CRANE": "crane",
|
||||
" crane ": "crane",
|
||||
"c-r-a-n-e": "crane",
|
||||
"héllo": "hllo", // strips non a-z (including the é and accented o-equivalent)
|
||||
"!@#$%": "",
|
||||
"42 crane": "crane",
|
||||
"": "",
|
||||
"crane": "crane",
|
||||
"CRANE": "crane",
|
||||
" crane ": "crane",
|
||||
"c-r-a-n-e": "crane",
|
||||
"héllo": "hllo", // strips non a-z (including the é and accented o-equivalent)
|
||||
"!@#$%": "",
|
||||
"42 crane": "crane",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeWord(in); got != want {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -39,6 +41,7 @@ type RecordingBot struct {
|
||||
mu sync.Mutex
|
||||
calls []SentCall
|
||||
failures map[string]failureResponse
|
||||
stubs map[string]string
|
||||
nextMessageID int
|
||||
}
|
||||
|
||||
@@ -87,15 +90,66 @@ func (rb *RecordingBot) LastSent() SentCall {
|
||||
return rb.calls[len(rb.calls)-1]
|
||||
}
|
||||
|
||||
// Reset drops all captured calls. Useful between sub-tests sharing one bot.
|
||||
// Reset drops all captured calls.
|
||||
//
|
||||
// It deliberately does NOT clear registered failures or stubs — those are setup,
|
||||
// not observations. A sub-test that needs different responses should register
|
||||
// them explicitly or build its own bot.
|
||||
func (rb *RecordingBot) Reset() {
|
||||
rb.mu.Lock()
|
||||
rb.calls = nil
|
||||
rb.mu.Unlock()
|
||||
}
|
||||
|
||||
// StubMethod makes method return resultJSON as the "result" field of an ok
|
||||
// response, so methods that decode into a struct can be exercised at all.
|
||||
//
|
||||
// Without a stub, okResponseFor answers every non-message-producing method with
|
||||
// `{"ok":true,"result":true}` — which getStickerSet, getFile, uploadStickerFile,
|
||||
// and getMe cannot decode, so under the bare harness they can only ever return
|
||||
// "json: cannot unmarshal bool" errors.
|
||||
//
|
||||
// resultJSON is the raw JSON value for "result" — an object, array, or scalar:
|
||||
//
|
||||
// rb.StubMethod("getStickerSet", `{"name":"p_by_bot","title":"P","sticker_type":"regular","stickers":[]}`)
|
||||
//
|
||||
// A failure registered for the same method wins, so a test can override a
|
||||
// stubbed happy path without unregistering the stub.
|
||||
func (rb *RecordingBot) StubMethod(method string, resultJSON string) {
|
||||
rb.mu.Lock()
|
||||
defer rb.mu.Unlock()
|
||||
if rb.stubs == nil {
|
||||
rb.stubs = map[string]string{}
|
||||
}
|
||||
rb.stubs[method] = resultJSON
|
||||
}
|
||||
|
||||
// FailMethodCode makes method fail with a Telegram-shaped error carrying an
|
||||
// error_code, so the library maps it to the same sentinel production emits
|
||||
// (bot.ErrorBadRequest for 400, bot.ErrorForbidden for 403, and so on).
|
||||
//
|
||||
// This is the difference from FailMethod: the library switches on the
|
||||
// error_code *in the response body* (raw_request.go:103-125), not the HTTP
|
||||
// status, so a codeless failure never takes a sentinel shape. Handlers that
|
||||
// classify errors with errors.Is must be tested through this method.
|
||||
//
|
||||
// rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERS_TOO_MUCH")
|
||||
func (rb *RecordingBot) FailMethodCode(method string, errorCode int, description string) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"ok": false,
|
||||
"error_code": errorCode,
|
||||
"description": description,
|
||||
})
|
||||
rb.FailMethod(method, errorCode, string(body))
|
||||
}
|
||||
|
||||
// FailMethod makes the recording server return a Telegram API error for a
|
||||
// specific method while still recording the attempted call.
|
||||
//
|
||||
// The body is emitted verbatim, so unless it carries an "error_code" field the
|
||||
// resulting error is **codeless**: the library returns a generic decode/status
|
||||
// error rather than bot.ErrorBadRequest or any other sentinel. Use
|
||||
// FailMethodCode when the test asserts on the error's classification.
|
||||
func (rb *RecordingBot) FailMethod(method string, status int, body string) {
|
||||
rb.mu.Lock()
|
||||
defer rb.mu.Unlock()
|
||||
@@ -118,22 +172,51 @@ func (rb *RecordingBot) FailMethod(method string, status int, body string) {
|
||||
func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) {
|
||||
method := apiMethodFromPath(r.URL.Path)
|
||||
|
||||
// 8 MiB cap — well above any realistic test payload but bounded for gosec.
|
||||
// #nosec G120 — explicit upper bound above
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
// Parameterless methods (getMe) send no body at all, so a parse failure is
|
||||
// not an error there — it just means there are no form fields to record.
|
||||
// Failing the request would make those methods untestable no matter what
|
||||
// the test registered.
|
||||
//
|
||||
// That tolerance is scoped to requests that carry no multipart body. A
|
||||
// request that claims to be multipart and then fails to parse is a real
|
||||
// fault, and answering it 200 with an empty Form would quietly satisfy
|
||||
// every test that asserts a field is *absent*.
|
||||
// Read the body before parsing, because an empty body and a corrupt one are
|
||||
// otherwise indistinguishable: multipart reports both as "no parts".
|
||||
//
|
||||
// The distinction matters. Parameterless methods (getMe) genuinely send no
|
||||
// body, and rejecting them would make those methods untestable. A body that
|
||||
// is present but unparseable is a real fault, and answering it 200 with an
|
||||
// empty form would quietly satisfy every test that asserts a field is
|
||||
// *absent* — several outside this package do exactly that.
|
||||
//
|
||||
// 8 MiB cap: well above any realistic test payload, bounded for gosec.
|
||||
const maxBody = 8 << 20
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
||||
if err != nil {
|
||||
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
form := make(map[string]string, len(r.MultipartForm.Value))
|
||||
for k, vs := range r.MultipartForm.Value {
|
||||
if len(vs) > 0 {
|
||||
form[k] = vs[0]
|
||||
|
||||
form := map[string]string{}
|
||||
if len(body) > 0 {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
// #nosec G120 — bounded by maxBody above
|
||||
if err := r.ParseMultipartForm(maxBody); err != nil {
|
||||
http.Error(w, "bad multipart form: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for k, vs := range r.MultipartForm.Value {
|
||||
if len(vs) > 0 {
|
||||
form[k] = vs[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rb.mu.Lock()
|
||||
rb.calls = append(rb.calls, SentCall{Method: method, Form: form})
|
||||
failure, shouldFail := rb.failures[method]
|
||||
stub, hasStub := rb.stubs[method]
|
||||
messageID := rb.nextMessageID
|
||||
if !shouldFail && isMessageProducingMethod(method) {
|
||||
rb.nextMessageID++
|
||||
@@ -142,11 +225,16 @@ func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) {
|
||||
rb.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Failures win over stubs so a test can override a stubbed happy path.
|
||||
if shouldFail {
|
||||
w.WriteHeader(failure.status)
|
||||
_, _ = w.Write([]byte(failure.body))
|
||||
return
|
||||
}
|
||||
if hasStub {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":` + stub + `}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(okResponseFor(method, messageID)))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
@@ -76,3 +79,105 @@ func TestUpdateBuilders_BotCommandEntity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StubMethod exists because the bare harness answers every non-message method
|
||||
// with `{"ok":true,"result":true}`, which any struct-decoding method rejects.
|
||||
// Without it, getStickerSet/getFile/uploadStickerFile/getMe can only error.
|
||||
func TestRecordingBot_StubMethodDecodesIntoStruct(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.StubMethod("getStickerSet", `{"name":"pack_by_bot","title":"My Pack","sticker_type":"regular","stickers":[{"file_id":"f1","file_unique_id":"u1","type":"regular"}]}`)
|
||||
|
||||
set, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "pack_by_bot"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetStickerSet: %v", err)
|
||||
}
|
||||
if set.Name != "pack_by_bot" || set.Title != "My Pack" {
|
||||
t.Errorf("set = %+v, want name pack_by_bot / title My Pack", set)
|
||||
}
|
||||
if len(set.Stickers) != 1 || set.Stickers[0].FileID != "f1" {
|
||||
t.Errorf("stickers = %+v, want one sticker f1", set.Stickers)
|
||||
}
|
||||
}
|
||||
|
||||
// A registered failure must beat a registered stub, so a test can override a
|
||||
// stubbed happy path without unregistering it.
|
||||
func TestRecordingBot_FailureWinsOverStub(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.StubMethod("getStickerSet", `{"name":"pack_by_bot"}`)
|
||||
rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
|
||||
if _, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "pack_by_bot"}); err == nil {
|
||||
t.Fatal("GetStickerSet succeeded; want the registered failure to win over the stub")
|
||||
}
|
||||
}
|
||||
|
||||
// The library classifies errors by the error_code in the response body, not the
|
||||
// HTTP status (raw_request.go), so only a coded failure produces the sentinel
|
||||
// that production error handling matches on.
|
||||
func TestRecordingBot_FailMethodCodeYieldsSentinel(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
|
||||
_, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "gone_by_bot"})
|
||||
if err == nil {
|
||||
t.Fatal("GetStickerSet succeeded; want an error")
|
||||
}
|
||||
if !errors.Is(err, bot.ErrorBadRequest) {
|
||||
t.Errorf("err = %v, want errors.Is(err, bot.ErrorBadRequest)", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "STICKERSET_INVALID") {
|
||||
t.Errorf("err = %v, want it to carry the MTProto code", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The contrast that FailMethod's doc comment promises: a codeless failure does
|
||||
// NOT take the sentinel shape. Handlers that classify with errors.Is must be
|
||||
// tested through FailMethodCode instead.
|
||||
func TestRecordingBot_FailMethodIsCodeless(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.FailMethod("getStickerSet", 400, `{"ok":false,"description":"Bad Request: STICKERSET_INVALID"}`)
|
||||
|
||||
_, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "gone_by_bot"})
|
||||
if err == nil {
|
||||
t.Fatal("GetStickerSet succeeded; want an error")
|
||||
}
|
||||
if errors.Is(err, bot.ErrorBadRequest) {
|
||||
t.Errorf("err = %v is bot.ErrorBadRequest; a codeless failure must not classify", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed multipart body must not be answered 200 with an empty form.
|
||||
//
|
||||
// The harness tolerates a parse failure only for parameterless calls, which
|
||||
// send no parts at all. Widening that to every parse failure would make the
|
||||
// server answer a corrupt request as though it carried no fields — quietly
|
||||
// satisfying any test that asserts a field is absent.
|
||||
func TestRecordingBot_RejectsMalformedMultipart(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
|
||||
resp, err := http.Post(rb.Server.URL+"/bottest-token/sendMessage",
|
||||
"multipart/form-data; boundary=zzz", strings.NewReader("not a multipart body at all"))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d for a corrupt multipart body", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// A parameterless call sends a multipart content type with no parts, and must
|
||||
// still be served — several API methods take no arguments.
|
||||
func TestRecordingBot_ServesParameterlessCall(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.StubMethod("getMe", `{"id":7,"is_bot":true,"first_name":"T","username":"testbot"}`)
|
||||
|
||||
me, err := rb.Bot.GetMe(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetMe: %v", err)
|
||||
}
|
||||
if me.Username != "testbot" {
|
||||
t.Errorf("username = %q, want testbot", me.Username)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Phase 1: Shared prerequisites"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Shared prerequisites
|
||||
|
||||
## Overview
|
||||
|
||||
Two gaps in shared code that the sticker module would otherwise expose. Both live outside
|
||||
`internal/modules/sticker` and both block every later phase, so they land first and merge
|
||||
independently of the feature.
|
||||
|
||||
1. **No panic barrier on the update path** (plan C9) — a handler panic terminates the
|
||||
process, and Phase 5 proposes decoding attacker-supplied images in a handler.
|
||||
2. **`RecordingBot` cannot return structured API results** (plan C11) — three later phases
|
||||
have success criteria that are unimplementable against the current harness.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: a panic in any command or callback handler is recovered, logged with the same
|
||||
context as a handler error, and does not terminate the process.
|
||||
- Functional: tests can make any Bot API method return a chosen JSON result, and can make a
|
||||
method fail with a Telegram-shaped error carrying an `error_code`.
|
||||
- Non-functional: no behaviour change for existing modules; every existing test passes
|
||||
unmodified.
|
||||
- Non-functional: the recovery path must not swallow the failure silently — it increments an
|
||||
error metric and logs at ERROR.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Panic barrier in `modules.Install`
|
||||
|
||||
`internal/modules/dispatcher.go:65-111` registers two closures per module — one for commands,
|
||||
one for callback data. Neither recovers. With `bot.WithNotAsyncHandlers()` (plan C1) the
|
||||
handler runs inline on the single polling goroutine, so an unrecovered panic ends the
|
||||
process for every user.
|
||||
|
||||
The repo already has the exact pattern to copy at `internal/cron/scheduler.go:66-74`, which
|
||||
recovers around cron handlers. Mirror it:
|
||||
|
||||
```go
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
metrics.IncError("handler-panic")
|
||||
log.Error("command panic", "command", cmdCopy.Name, "recovered", rec,
|
||||
"stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
Apply to both closures. The callback variant should also attempt `AnswerCallbackQuery` so
|
||||
the user's client stops showing a spinner, guarded so a failure there cannot panic again.
|
||||
|
||||
**Also fix the stale comment at `internal/modules/dispatcher.go:167`,** which claims
|
||||
"would panic the goroutine before our `recover()` in webhook.go".
|
||||
`internal/telegram/webhook.go` contains only `DeleteWebhook` — there has been no
|
||||
webhook-served handler since the move to long polling. The comment currently tells a reader
|
||||
a protection exists that does not.
|
||||
|
||||
### `RecordingBot` structured responses
|
||||
|
||||
`internal/testutil/recording_bot.go:178-195` (`okResponseFor`) returns
|
||||
`{"ok":true,"result":true}` for every method not in `isMessageProducingMethod`
|
||||
(`:156-165`). Methods decoding into a struct therefore always error:
|
||||
|
||||
| Method | Decodes into | Current test behaviour |
|
||||
|---|---|---|
|
||||
| `getStickerSet` | `*models.StickerSet` | `json: cannot unmarshal bool` |
|
||||
| `getFile` | `*models.File` | same |
|
||||
| `uploadStickerFile` | `*models.File` | same |
|
||||
| `getMe` | `*models.User` | same |
|
||||
|
||||
Add two capabilities, both additive:
|
||||
|
||||
```go
|
||||
// StubMethod makes method return the given JSON as its "result" field.
|
||||
func (rb *RecordingBot) StubMethod(method string, resultJSON string)
|
||||
|
||||
// FailMethodCode makes method fail with a Telegram-shaped error carrying an
|
||||
// error_code, so library errors take the same ErrorBadRequest / ErrorForbidden
|
||||
// shape production emits.
|
||||
func (rb *RecordingBot) FailMethodCode(method string, errorCode int, description string)
|
||||
```
|
||||
|
||||
`FailMethod` (`:99-112`) stays as-is for existing callers, but its doc comment must state
|
||||
that it produces a **codeless** failure that does **not** take the `ErrorBadRequest` shape —
|
||||
that distinction is what plan rule 4 depends on, and a future reader must not confuse the two.
|
||||
|
||||
Precedence when both a stub and a failure are registered for one method: the failure wins,
|
||||
so a test can override a stubbed happy path without unregistering it.
|
||||
|
||||
### Why this is a separate phase
|
||||
|
||||
Both changes touch files every other module's tests depend on
|
||||
(`internal/modules/dispatcher.go`, `internal/testutil/recording_bot.go`). Landing them
|
||||
alone, with the full suite green, keeps the blast radius reviewable and means a problem here
|
||||
is not entangled with sticker logic.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `internal/modules/dispatcher.go` (recover in both closures; fix the stale comment)
|
||||
- Modify: `internal/testutil/recording_bot.go` (`StubMethod`, `FailMethodCode`, doc fix)
|
||||
- Create: `internal/modules/dispatcher_panic_test.go`
|
||||
- Modify: `internal/testutil/recording_bot_test.go`
|
||||
- Reference: `internal/cron/scheduler.go:66-74` (the pattern to mirror)
|
||||
- Reference: `internal/metrics` (`IncError`), `internal/log` (`Error`)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add the recover to the command closure in `Install`, with metric + structured log.
|
||||
2. Add the recover to the callback closure, including a guarded `AnswerCallbackQuery`.
|
||||
3. Correct the stale comment at `dispatcher.go:167`.
|
||||
4. Add `StubMethod` and `FailMethodCode` to `RecordingBot`; document `FailMethod`'s codeless
|
||||
shape.
|
||||
5. Tests per the Todo list.
|
||||
6. Run the full suite — every existing test must pass untouched.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `recover()` in the command closure with `metrics.IncError("handler-panic")`
|
||||
- [x] `recover()` in the callback closure with guarded `AnswerCallbackQuery`
|
||||
- [x] Fix the stale `recover()` comment at `dispatcher.go:167`
|
||||
- [x] `RecordingBot.StubMethod(method, resultJSON)`
|
||||
- [x] `RecordingBot.FailMethodCode(method, errorCode, description)`
|
||||
- [x] Document that `FailMethod` produces a codeless failure
|
||||
- [x] `dispatcher_panic_test.go`: panicking command handler
|
||||
- [x] `dispatcher_panic_test.go`: panicking callback handler
|
||||
- [x] `recording_bot_test.go`: stubbed `getStickerSet` decodes into `models.StickerSet`
|
||||
- [x] `recording_bot_test.go`: `FailMethodCode` yields `bot.ErrorBadRequest`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] A command handler that panics is recovered; the test process survives and the error metric increments
|
||||
- [x] A callback handler that panics is recovered and the callback query is still answered
|
||||
- [x] `rg "recover\(\)" internal/modules/dispatcher.go` returns two hits
|
||||
- [x] No comment in the repo claims a `recover()` exists in `webhook.go`
|
||||
- [x] `rb.StubMethod("getStickerSet", ...)` lets `b.GetStickerSet` return a populated `*models.StickerSet` with a nil error
|
||||
- [x] `rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")` produces an error satisfying `errors.Is(err, bot.ErrorBadRequest)`
|
||||
- [x] `go test ./...` passes with no changes to any existing test file other than additions
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Recovering a panic can mask a real bug.** A handler that panics on every invocation would
|
||||
now fail quietly per-request instead of crashing loudly. Mitigation: log at ERROR with the
|
||||
full stack and increment a distinct `handler-panic` metric, so the condition is visible
|
||||
rather than silent. This is the same trade the cron scheduler already made
|
||||
(`cron/scheduler.go:66-74`); consistency with it is worth more than a second opinion here.
|
||||
|
||||
**Changing shared test infrastructure can break other modules' tests.** Both additions are
|
||||
new methods; no existing signature or default behaviour changes. The success criterion
|
||||
"no changes to any existing test file other than additions" is what proves it.
|
||||
|
||||
**Scope note.** The panic barrier is a pre-existing repo-wide gap, not one this module
|
||||
introduces — the module only makes it far easier to reach. It is included here on an
|
||||
explicit user decision rather than as silent scope expansion.
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Phase 2: Store, set names, emoji parsing"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 2: Store, set names, emoji parsing
|
||||
|
||||
## Overview
|
||||
|
||||
Pure, Telegram-free foundation: the persisted pack record, the mapping between a
|
||||
user-chosen slug and a Telegram set name, sender validation, and emoji-argument parsing.
|
||||
Every function here is unit-testable without a bot.
|
||||
|
||||
One pack per user means the store layer is a single keyed record, not a collection scan.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: persist at most one pack record per user, keyed so the caller's own user ID
|
||||
is the whole key — making the lookup itself the ownership check.
|
||||
- Functional: construct a Telegram set name `<slug>_by_<bot_username>` at creation, and match
|
||||
a sticker's `set_name` against the stored pack **without** re-deriving it from the live
|
||||
username.
|
||||
- Functional: reject senders that are bots or anonymous chat surrogates.
|
||||
- Functional: split an emoji argument run into individual emoji, accepting `😂 🔥` and `😂🔥`.
|
||||
- Non-functional: no network calls and no Telegram API types in the store layer.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pack record
|
||||
|
||||
```go
|
||||
// Pack is the single bot-created sticker set owned by a Telegram user.
|
||||
type Pack struct {
|
||||
Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL
|
||||
Name string `bson:"name"` // Telegram set name, "<slug>_by_<botname>"
|
||||
Title string `bson:"title"` // display title, mutable
|
||||
OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to
|
||||
Count int `bson:"count"` // stickers in the set; keeps /mypack API-free
|
||||
Pending bool `bson:"pending"` // write-ahead intent; see Phase 3
|
||||
CreatedAt int64 `bson:"createdAt"` // unix millis
|
||||
}
|
||||
```
|
||||
|
||||
- **Key: `strconv.FormatInt(ownerID, 10)`** — the user ID alone. One pack per user makes the
|
||||
slug unnecessary as a key component, which removes the prefix scan entirely.
|
||||
- `getPack(ctx, ownerID) (Pack, bool, error)` — one `Get`; `storage.ErrNotFound` maps to
|
||||
`false, nil`. There is no `listPacks` and no `List` call anywhere in the module.
|
||||
|
||||
This retires the worst red-team finding. The previous `listPacks` was a structural N+1
|
||||
(`mongo_doc_store.go:157-165` projects `_id` only, forcing a `Get` per key), and
|
||||
`/packlist` layered ten `GetStickerSet` calls on top of it under a 60s-per-call ceiling.
|
||||
|
||||
- `Count` keeps `/mypack` free of API calls. It is **advisory** — a user editing the pack
|
||||
through @Stickers desyncs it. Phase 3 refreshes it opportunistically.
|
||||
- `Pending` implements write-ahead intent (plan C5). Phase 3 owns the state machine.
|
||||
- `Pack`'s bson tags must not collide with `_id` / `version` / `updatedAt`; `storage.Typed`
|
||||
panics on collision (`internal/storage/doc_store.go:72`).
|
||||
|
||||
### Sender validation
|
||||
|
||||
```go
|
||||
// senderID returns the personal Telegram user behind msg, or an error when the
|
||||
// message has no usable personal identity.
|
||||
func senderID(msg *models.Message) (int64, error)
|
||||
```
|
||||
|
||||
Rejects, in order: nil `msg`/`From`, zero ID, `From.IsBot`, and non-nil `msg.SenderChat`.
|
||||
|
||||
The last two are not theoretical. Telegram substitutes a single global `GroupAnonymousBot`
|
||||
user for **every** anonymous group-admin message and puts the real origin in `SenderChat`
|
||||
(`models/message.go:86-87`). Without this check, all anonymous admins across all groups would
|
||||
share one pack — and under one-pack-per-user that is worse than it was before, because the
|
||||
first anonymous admin to run `/newpack` would block every other one and own the result.
|
||||
`rg "IsBot" internal/` returns zero hits today; `coin`, `gold`, and `stock` check only
|
||||
`From != nil && From.ID != 0`, which is safe for paper-trading state but not for durable
|
||||
Telegram objects.
|
||||
|
||||
The refusal must explain the fix ("sticker packs need a personal account — turn off anonymous
|
||||
posting for this message"), not just deny.
|
||||
|
||||
### Set names
|
||||
|
||||
- `slugRe = ^[a-z][a-z0-9_]{2,39}$` — 3 to 40 chars. Additionally reject `__` (Telegram
|
||||
forbids consecutive underscores) and a trailing `_`. The cap is for link readability and to
|
||||
stay inside the 64-char set-name budget.
|
||||
- `makeSetName(slug, botUsername) (string, error)` → `slug + "_by_" + botUsername`, erroring
|
||||
above 64 chars and reporting the remaining slug budget so the reply can say "max N
|
||||
characters".
|
||||
- **`ownsSet(pack Pack, setName string) bool`** — case-insensitive comparison of `setName`
|
||||
against the stored `pack.Name`. This is the ownership resolver used by Phase 4.
|
||||
|
||||
It deliberately replaces a `parseSlug(setName, botUsername)` design that re-derived the slug
|
||||
from the *live* username and discarded the persisted `Pack.Name`. Renaming the bot in
|
||||
BotFather — supported, and it leaves existing set names untouched — would have made every
|
||||
user's own pack refuse as "not yours" while `/mypack` still displayed it. Comparing the
|
||||
stored name also removes a case-sensitivity trap, since Telegram returns `SetName` with
|
||||
whatever casing the set was created with (plan R8).
|
||||
|
||||
- `usernameResolver` caches `GetMe` and **must not cache failures**. The bot starts with
|
||||
`bot.WithSkipGetMe()` (`internal/telegram/client.go:26`), so nothing populates a username
|
||||
until the module asks. It is used **only** by `/newpack` to name a new set — never for
|
||||
ownership. It takes the handler's `b *bot.Bot`, **not** `deps.Bot`, which is documented
|
||||
nil-safe (`internal/modules/module.go:88`) and is nil under `BuildOptions{}`
|
||||
(`cmd/server/command_menu_test.go:55`).
|
||||
|
||||
### Emoji parsing
|
||||
|
||||
`parseEmoji(args []string) ([]string, error)` — join arguments, then split into clusters:
|
||||
|
||||
- keep ZWJ (`U+200D`) sequences together;
|
||||
- absorb variation selectors (`U+FE0F`/`U+FE0E`), skin-tone modifiers (`U+1F3FB`–`U+1F3FF`),
|
||||
and combining marks into the preceding cluster;
|
||||
- pair regional indicators (`U+1F1E6`–`U+1F1FF`);
|
||||
- keep keycap sequences (`<base> U+FE0F U+20E3`) together.
|
||||
|
||||
Reject non-emoji text with a usage error. Cap at 20 (`emoji_list` is documented 1–20; the
|
||||
server's own message is the literal `too many emoji specified`). `defaultEmoji = "⭐"`.
|
||||
|
||||
Because `/addsticker` now takes only `[emoji...]`, every one of its arguments is an emoji —
|
||||
there is no first-token disambiguation to perform, and a stray word fails loudly here rather
|
||||
than being mistaken for a pack name.
|
||||
|
||||
`models.Sticker.Emoji` is a **single string** (`models/sticker.go:23`), so emoji inherited
|
||||
from a replied sticker yields at most one element.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/sticker/pack.go`, `setname.go`, `sender.go`, `emoji.go`
|
||||
- Create: matching `_test.go` files for each
|
||||
- Reference: `internal/storage/doc_store.go:38` (DocStore contract), `keys.go:24-41`
|
||||
- Reference: `internal/modules/coin/handlers_test.go:36` (memory-store test pattern)
|
||||
- Reference: `models/message.go:86-87` (`SenderChat`), `models/user.go:12` (`IsBot`),
|
||||
`models/sticker.go:23` (`Emoji` is one string)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `pack.go` — record, `packKey`, `getPack`.
|
||||
2. `sender.go` — `senderID` with the bot/anonymous refusals.
|
||||
3. `setname.go` — slug validation, `makeSetName`, `ownsSet`, cached resolver interface.
|
||||
4. `emoji.go` — cluster scanner and `defaultEmoji`.
|
||||
5. Tests per the Todo list.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] Define `Pack` incl. `Count` and `Pending`; assert no reserved-bson collision
|
||||
- [x] `packKey(ownerID)` and `getPack` returning a found flag
|
||||
- [x] `senderID` rejecting nil/zero/`IsBot`/`SenderChat` with an explanatory message
|
||||
- [x] `slugRe` validation incl. `__`, trailing `_`, 40-char cap
|
||||
- [x] `makeSetName` with 64-char guard and budget-reporting error
|
||||
- [x] `ownsSet` case-insensitive match against stored `Pack.Name`
|
||||
- [x] `usernameResolver` caching success but never failure, taking the handler's `b`
|
||||
- [x] `parseEmoji` cluster scanner with the 20-entry cap
|
||||
- [x] Four test files per the success criteria
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `getPack` for owner A never returns owner B's pack
|
||||
- [x] `getPack` on an unknown owner returns `found == false` and a nil error
|
||||
- [x] No `List` call exists anywhere in the module
|
||||
- [x] Slug table rejects leading digit, `__`, trailing `_`, 2 chars, 41 chars
|
||||
- [x] `makeSetName` errors when `len(slug)+len("_by_"+username) > 64`
|
||||
- [x] `ownsSet` matches `MyPack_by_Bot` against a stored `mypack_by_bot`
|
||||
- [x] `ownsSet` returns false for a set name belonging to another bot
|
||||
- [x] A simulated bot username change does **not** break `ownsSet` for an existing pack
|
||||
- [x] `senderID` rejects `IsBot: true` and a non-nil `SenderChat`, each with zero store access
|
||||
- [x] `parseEmoji` handles joined input, ZWJ family, flag, keycap, skin tone; rejects plain text; errors above 20
|
||||
- [x] `gofmt -l internal/modules/sticker` empty; `go test`/`go vet` clean
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Emoji cluster scanning is hand-rolled.** Go has no stdlib grapheme segmentation and a
|
||||
dependency for this is disproportionate. Signal: a user reports an emoji split or rejected.
|
||||
Response: extend the table-driven test with the failing sequence. If failures accumulate
|
||||
across many scripts, take a segmentation dependency.
|
||||
|
||||
**`Count` can drift.** Editing the pack through @Stickers changes the real count without the
|
||||
bot seeing it. Accepted: the field is advisory and feeds one display column. Phase 3 refreshes
|
||||
it whenever a command already holds a `GetStickerSet` response, so it self-heals without any
|
||||
command paying for a lookup it did not otherwise need.
|
||||
|
||||
**Keying on the user ID alone bakes in the one-pack limit.** Reversing to multiple packs later
|
||||
means a key migration, not just new commands — every existing record would need rewriting
|
||||
under a compound key. That is the real cost of plan R10, and it is why the limit belongs in
|
||||
the plan rather than living as a constant someone can bump.
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Phase 3: Pack lifecycle commands"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "7h"
|
||||
dependencies: [1, 2]
|
||||
---
|
||||
|
||||
# Phase 3: Pack lifecycle commands
|
||||
|
||||
## Overview
|
||||
|
||||
`/newpack`, `/mypack`, `/renamepack`, `/delpack` (+ confirm callback). These own creation and
|
||||
destruction of the user's single pack, and they carry the plan's two hardest correctness
|
||||
problems: surviving partial failure, and making an irreversible delete safe to confirm.
|
||||
|
||||
All handlers follow the plan's cross-cutting rules (explicit deadline, `WithoutCancel`
|
||||
commits, `senderID`, positive error classification).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: create the caller's pack and persist its record such that no interruption can
|
||||
permanently strand the slug.
|
||||
- Functional: show, rename, and delete that pack, never another user's.
|
||||
- Functional: a second `/newpack` while a pack exists is refused with a clear next step.
|
||||
- Functional: `/delpack` confirmation is bound to invoker, chat, message, and a TTL.
|
||||
- Functional: `/mypack` makes zero API calls.
|
||||
- Non-functional: a store record must never claim a pack that does not exist, and a live pack
|
||||
must never lose its record because of a transient error.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Factory
|
||||
|
||||
Mirrors `internal/modules/coin/coin.go`. `state` holds `store`, `pending` (a second typed view
|
||||
for delete confirmations — the pattern is idiomatic here; `loldle` and `lol` each build three
|
||||
views over one collection), `resolver`, `locks keylock.Map`, `nowFn`.
|
||||
|
||||
Registry key is `sticker`; command names stay unprefixed — the registry keys commands by
|
||||
`cmd.Name` independent of module name (`registry.go:176`), which is why `misc` ships `/ff`.
|
||||
|
||||
`handlerTimeout = 10 * time.Second` is a package constant; every handler opens with it.
|
||||
|
||||
> **Superseded during implementation — see plan.md, "post-implementation review:
|
||||
> global slug reservation".** Step 5 below adopts an existing set on the strength
|
||||
> of a `Pending` record for this owner and slug. Review proved that insufficient:
|
||||
> the pack record is keyed by owner, so a user with *no* pack who types someone
|
||||
> else's slug produces identical evidence and took over their pack. The shipped
|
||||
> code adds a create-only global reservation (`slug:<slug>` → ownerID) written
|
||||
> before Telegram is touched, and adopts only when it names the caller. Steps 4-7
|
||||
> read as implemented **except** that a reservation check precedes step 4, and
|
||||
> step 5's "abort, delete the pending record" on an unknown error is wrong for
|
||||
> the same reason rule 4 exists — the shipped code keeps both the intent and the
|
||||
> reservation unless the refusal is positively classified.
|
||||
|
||||
### `/newpack <pack> <title...>` — write-ahead intent
|
||||
|
||||
`<pack>` appears here and nowhere else in the module. It fixes the permanent share URL, and
|
||||
Telegram has no rename-short-name method, so it cannot be corrected later.
|
||||
|
||||
An earlier draft did "create on Telegram, then write the store", accepting that an interruption
|
||||
stranded the slug forever. Two review findings killed that: the commit ran on `rootCtx`, which
|
||||
SIGTERM cancels, so **every deploy** during a `/newpack` stranded a slug; and the
|
||||
"cannot adopt" rule was not forced by the API's missing owner field (plan C5). The bot does not
|
||||
need the API to name the owner — it needs its own record of who asked.
|
||||
|
||||
1. `senderID(msg)`; `defer s.locks.Acquire(...)()`.
|
||||
2. Parse slug and title (1–64 chars).
|
||||
3. `makeSetName(slug, username)`.
|
||||
4. **`PutVersioned(ctx, key, 0, Pack{Pending: true, …})`** — the create-only primitive
|
||||
(`doc_store.go:33-37`; Mongo gives a linearizable single-winner via duplicate-key,
|
||||
`mongo_doc_store.go:87-105`). This *is* the one-pack quota — no separate counter exists.
|
||||
On `ErrConflict`, read the record:
|
||||
- confirmed → "you already have a pack (`<slug>`). Use /delpack first." Stop.
|
||||
- `Pending` with the **same** slug → this is our own interrupted attempt; resume at step 5.
|
||||
- `Pending` with a **different** slug → an earlier attempt was interrupted. **Probe
|
||||
`GetStickerSet(oldName)` before doing anything.**
|
||||
- The old set **exists** → the earlier attempt got as far as creating it. Adopt the old
|
||||
set, commit it, and tell the user they already have a pack (`<oldSlug>`) and must
|
||||
`/delpack` first if they want the new name. Do **not** overwrite.
|
||||
- The old set is **missing** (`isStickerSetMissing`) → nothing was created; overwrite the
|
||||
pending record with the new slug and continue.
|
||||
- **Any other error** → unknown; abort without touching the record.
|
||||
|
||||
Overwriting unconditionally would orphan a created-but-uncommitted set permanently: the
|
||||
set exists and is owned by the user, but adoption keys on the pending slug matching, so
|
||||
`/newpack <oldSlug>` would afterwards report "taken" with no route back. The probe is what
|
||||
makes the different-slug branch safe.
|
||||
|
||||
Use `Put` nowhere here — it is a 5-attempt Get→PutVersioned loop
|
||||
(`mongo_doc_store.go:120-142`) that silently overwrites.
|
||||
5. `GetStickerSet(name)`:
|
||||
- **succeeds** → the set exists. We hold a `Pending` record for this owner and slug, so this
|
||||
is our own interrupted attempt: **adopt it**, jump to step 7.
|
||||
- **`isStickerSetMissing`** → the slug is free; proceed to step 6.
|
||||
- **any other error** → unknown. Abort, delete the pending record, reply generic failure.
|
||||
Never guess (plan rule 4).
|
||||
6. `CreateNewStickerSet{UserID, Name, Title, Stickers: []InputSticker{{Sticker: fileID, Format: "static", EmojiList: emoji}}}`.
|
||||
No top-level `sticker_format` — it moved to `InputSticker.Format` in Bot API 7.2 (C7).
|
||||
On `PACK_SHORT_NAME_OCCUPIED`, another user of this bot holds the slug: delete the pending
|
||||
record and ask for a different one.
|
||||
7. Commit: `Put(context.WithoutCancel(ctx), key, Pack{Pending: false, Count: 1, …})`.
|
||||
Reply with the title and `https://t.me/addstickers/<name>`.
|
||||
|
||||
Re-running `/newpack` with the same slug after any interruption completes the operation instead
|
||||
of reporting it taken. That is the plan's "interrupted `/newpack` can be completed by re-running"
|
||||
criterion.
|
||||
|
||||
### `/mypack` — zero API calls
|
||||
|
||||
`getPack(senderID)`. One `Get`. Renders slug, title, `Count`, and the share link, or a short
|
||||
"you don't have a pack yet — `/newpack <name> <title>`" when absent. A `Pending` record renders
|
||||
with an "(incomplete — re-run /newpack)" marker rather than being hidden, so a stranded attempt
|
||||
is visible and fixable.
|
||||
|
||||
This replaces the multi-pack `/packlist`, which issued one `GetStickerSet` per pack. Under plan
|
||||
C2 each call is bounded only by the library's 60s `http.Client` timeout
|
||||
(`bot.go:17-18,75-77`), so ten of them on a serialized dispatcher was a ~10-minute bot-wide
|
||||
freeze from one argument-free public command. That failure mode no longer exists.
|
||||
|
||||
### `/renamepack <title...>`
|
||||
|
||||
`getPack(senderID)`; absent → "you don't have a pack yet". `SetStickerSetTitle{Name, Title}`,
|
||||
then commit the new `Title` under `WithoutCancel`.
|
||||
|
||||
The reply must state the share link is unchanged **and name the route to a different one**:
|
||||
`/delpack` then `/newpack <new-slug>`. This matters more now that the command takes no slug — a
|
||||
user typing "rename" with only a title is even likelier to expect the URL to follow, and it
|
||||
never can. Pointing at the real path turns a dead end into an answer.
|
||||
|
||||
Required elements of the reply: the new title, the unchanged link, and the delete-and-recreate
|
||||
route with its cost stated (the stickers do not come along).
|
||||
|
||||
**Reverse gap:** if the API succeeds and the commit fails, `/mypack` shows a title Telegram no
|
||||
longer has. Cosmetic, self-heals on the next successful rename. Documented, not mitigated.
|
||||
|
||||
### `/delpack` — no argument, bound confirmation
|
||||
|
||||
An earlier draft put the slug in the callback data and re-checked ownership from
|
||||
`CallbackQuery.From.ID`. That defends against *other* users pressing the button and nothing
|
||||
else: the payload never expired, was not bound to a chat or message, and lived in scrollback
|
||||
forever. Three reviewers flagged it independently, and the `stock` module the draft cited as its
|
||||
model already solves it properly.
|
||||
|
||||
With one pack per user the payload needs no slug at all — but it still needs everything else:
|
||||
|
||||
1. `/delpack` resolves the caller's pack, then writes a pending action:
|
||||
`pendingDelete{ID, OwnerID, Slug, ChatID, MessageID, ExpiresAt}` with
|
||||
`pendingDeleteTTL = 10 * time.Minute`. (`stock/pending_dividend.go:12-16,26-34` uses 24h for
|
||||
a non-destructive action; a destructive one earns a shorter window.)
|
||||
|
||||
The confirm prompt must state all four consequences before the tap, since the command itself
|
||||
names nothing:
|
||||
|
||||
- the pack title being deleted;
|
||||
- **the sticker count that will be lost** (`Pack.Count`);
|
||||
- the exact share link that will stop working;
|
||||
- that both are permanent.
|
||||
|
||||
`/delpack` is the sanctioned way to change a pack URL, so this prompt is the last point at
|
||||
which a user learns the stickers do not survive the change. Understating it here is how
|
||||
someone loses 47 stickers expecting a rename.
|
||||
2. Callback data is `sticker_pack:d:<opaque id>` — comfortably inside the 64-byte cap (C3).
|
||||
3. The callback handler:
|
||||
- returns early when `update.CallbackQuery == nil`;
|
||||
- resolves the pending action; absent → "this confirmation expired or was already used"
|
||||
(`stock/dividend_callback.go:66-80` is the model);
|
||||
- checks `query.From.ID == action.OwnerID` — identity from `From.ID`, **never** the payload;
|
||||
- checks the chat/message binding and `ExpiresAt`;
|
||||
- guards `query.Message.Message` for nil — it is a `MaybeInaccessibleMessage`
|
||||
(`models/message.go:17-21`), nil for messages Telegram marks inaccessible.
|
||||
`stock/dividend_callback.go:82-83` already guards exactly this. Phase 1's panic barrier is
|
||||
the backstop, not an excuse to skip the guard;
|
||||
- deletes the pending action **before** calling `DeleteStickerSet` (single-use);
|
||||
- `DeleteStickerSet{Name}`, then `store.Delete` under `WithoutCancel`;
|
||||
- `AnswerCallbackQuery` and clear the button via `EditMessageReplyMarkup` with empty markup,
|
||||
using `action.ChatID`/`action.MessageID` (`dividend_callback.go:26-33`).
|
||||
|
||||
**Reverse gap:** if `DeleteStickerSet` succeeds and `store.Delete` fails, a phantom record
|
||||
survives — and under one-pack-per-user that is worse than before, because it blocks `/newpack`
|
||||
entirely rather than consuming one of ten slots. Mitigation: any command receiving
|
||||
`isStickerSetMissing` from the API deletes the record on the spot, so the phantom clears on
|
||||
first contact and `/newpack` works again.
|
||||
|
||||
### Error mapping
|
||||
|
||||
`replyAPIError` matches **MTProto code substrings**, never human text (plan rule 4 / R3). Only
|
||||
`PACK_SHORT_NAME_OCCUPIED`, `PACK_SHORT_NAME_INVALID`, and `STICKER_EMOJI_INVALID` are rewritten
|
||||
into prose by the Bot API server; everything else arrives as `Bad Request: <CODE>`.
|
||||
|
||||
| Match | Reply |
|
||||
|---|---|
|
||||
| `PACK_SHORT_NAME_OCCUPIED` / "already occupied" | slug taken, pick another |
|
||||
| `PACK_SHORT_NAME_INVALID` / "invalid sticker set name" | slug rejected by Telegram |
|
||||
| `PACK_TITLE_INVALID` | title rejected by Telegram |
|
||||
| `STICKERSET_INVALID` | your pack no longer exists (and delete the record) |
|
||||
| `STICKERS_TOO_MUCH` | pack is full (120 stickers) |
|
||||
| `STICKER_EMOJI_INVALID` / "invalid sticker emojis" | emoji rejected |
|
||||
| `too many emoji specified` | at most 20 emoji per sticker |
|
||||
| anything else | generic failure; raw error to the dispatcher log |
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/sticker/sticker.go`, `state.go`, `pack_handlers.go`,
|
||||
`pending_delete.go`, `delpack_callback.go`, `errors.go`, and their tests
|
||||
- Reference: `internal/modules/coin/coin.go`, `coin/handlers.go:51` (keylock idiom)
|
||||
- Reference: `internal/modules/stock/pending_dividend.go:12-34,47-78`,
|
||||
`stock/dividend_callback.go:17-33,66-83,99-102`, `stock/dividend_notifications.go:303-320`
|
||||
- Reference: `internal/storage/doc_store.go:33-41`, `mongo_doc_store.go:87-142`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `state.go`, `sticker.go` wiring four commands + the `sticker_pack:` callback.
|
||||
2. `errors.go` with `replyAPIError` and `isStickerSetMissing`.
|
||||
3. `/mypack` first — no mutations, no API calls, easiest to verify.
|
||||
4. `/newpack` with the write-ahead state machine.
|
||||
5. `/renamepack`.
|
||||
6. `pending_delete.go`, `/delpack`, and the callback.
|
||||
7. Tests per the Todo list, using Phase 1's `StubMethod` / `FailMethodCode`.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `state.go` with store, pending view, resolver, locks, nowFn, `handlerTimeout`
|
||||
- [x] `sticker.go` factory registering the module's commands + callback prefix (9 as shipped, once phases 4-5 landed)
|
||||
- [x] `errors.go`: `replyAPIError` code table + `isStickerSetMissing`
|
||||
- [x] `/mypack` reading `Count`, marking a pending record, zero API calls
|
||||
- [x] `/newpack` steps 1-7 incl. `PutVersioned` intent, three `ErrConflict` branches, adoption
|
||||
- [x] Different-slug pending branch probes `GetStickerSet(oldName)` before overwriting
|
||||
- [x] `/renamepack` reply: new title, unchanged link, and the /delpack + /newpack route
|
||||
- [x] `/delpack` confirm prompt: title, sticker count, link, permanence
|
||||
- [x] `pending_delete.go` with TTL, chat/message binding, opaque id
|
||||
- [x] `/delpack` naming the pack in its confirm prompt
|
||||
- [x] `delpack_callback.go` with expiry, binding, nil-message guard, single-use
|
||||
- [x] Record self-heal on `isStickerSetMissing` across commands
|
||||
- [x] Tests per the success criteria
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `/mypack` records **zero** entries in `RecordingBot.Sent()`
|
||||
- [x] A second `/newpack` with a confirmed pack present is refused, names the existing slug, and makes zero API calls
|
||||
- [x] Interrupted `/newpack` (pending record, same slug, set exists) completes on re-run and does not report the slug taken
|
||||
- [x] Interrupted `/newpack` with a *different* slug where the old set **exists** adopts the old set and refuses the new slug, leaving nothing orphaned
|
||||
- [x] Interrupted `/newpack` with a *different* slug where the old set is **missing** replaces the pending record and proceeds
|
||||
- [x] `/newpack` where `GetStickerSet` fails with a non-missing error aborts and never calls `CreateNewStickerSet`, **keeping** the pending record and the reservation — superseded, see the note at the top of this file. Deleting them on an unknown error is what strands a slug: the set may exist, and re-running is how the user recovers.
|
||||
- [x] `/newpack` uses `PutVersioned(…, 0, …)`; the create path never calls `Put` for a new record
|
||||
- [x] `/renamepack` with no pack replies "you don't have a pack yet" and makes zero API calls
|
||||
- [x] `/delpack` confirm after `ExpiresAt` is refused as expired, with no `DeleteStickerSet`
|
||||
- [x] `/delpack` confirm from a different `From.ID` is refused, with no `DeleteStickerSet`
|
||||
- [x] `/delpack` confirm with a nil `CallbackQuery.Message.Message` is handled without panic
|
||||
- [x] Pressing the same confirm twice deletes once; the second press reports already-used
|
||||
- [x] Callback data is asserted ≤ 64 bytes
|
||||
- [x] A command receiving `STICKERSET_INVALID` deletes the stale record, unblocking `/newpack`
|
||||
- [x] Title of 65 chars rejected locally, before any API call
|
||||
- [x] `/delpack` confirm text contains the pack title, the sticker count, and the share link
|
||||
- [x] `/renamepack` reply names the `/delpack` + `/newpack` route
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**The write-ahead state machine is the most intricate logic in the plan**, and one-pack-per-user
|
||||
adds a branch rather than removing one: `ErrConflict` now means three different things
|
||||
(confirmed pack, own pending same-slug, own pending different-slug). Its correctness rests on
|
||||
one property — a `Pending` record for an owner means *that owner* asked for *that name*, and
|
||||
only this bot can create `*_by_<bot_username>` names. If either half stops holding, adoption
|
||||
becomes unsafe. Signal: a user reports adopting a pack they did not create. Response: disable
|
||||
adoption (step 5 becomes "slug taken") and fall back to the documented orphan gap — a one-line
|
||||
change, deliberately.
|
||||
|
||||
**A stranded pending record now blocks the user entirely.** With ten slots it cost one; with one
|
||||
pack it blocks `/newpack` until resolved. Mitigated by making it visible in `/mypack` with a
|
||||
re-run hint, and by the different-slug overwrite branch in step 4 so a user is never wedged by
|
||||
a name they no longer want.
|
||||
|
||||
**`/delpack` remains irreversible on Telegram's side.** The TTL and bindings reduce accidental
|
||||
confirmation; they cannot undo a deliberate one. Do not add a `--force` bypass.
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "Phase 4: Sticker commands (reply path)"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [1, 2, 3]
|
||||
---
|
||||
|
||||
# Phase 4: Sticker commands (reply path)
|
||||
|
||||
## Overview
|
||||
|
||||
Per-sticker operations on the caller's pack: `/addsticker` (existing-sticker source),
|
||||
`/delsticker`, `/editsticker`, `/ordersticker`. All are driven by replying to a sticker. The
|
||||
photo source arrives in Phase 5 through the same `/addsticker` handler.
|
||||
|
||||
None of these takes a pack argument. Under one-pack-per-user there is nothing to name.
|
||||
|
||||
All handlers follow the plan's cross-cutting rules.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: add an existing static sticker to the caller's pack.
|
||||
- Functional: remove, re-emoji, and reposition a sticker already in that pack.
|
||||
- Non-functional: ownership is checked before any API call, and the refusal text is identical
|
||||
for "another user's pack" and "another bot's pack".
|
||||
- Non-functional: no transient error may delete a live pack's record.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Shared resolution
|
||||
|
||||
```go
|
||||
// source of a NEW sticker: whatever the replied message carries. Resolution
|
||||
// always ends in a file_id usable as InputSticker.Sticker.
|
||||
type stickerSource struct {
|
||||
fileID string // static sticker file_id, or a freshly uploaded one (Phase 5)
|
||||
emoji []string // from the replied sticker; at most one element
|
||||
}
|
||||
func (s *state) resolveSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error)
|
||||
|
||||
// an EXISTING sticker in the caller's pack
|
||||
type ownedSticker struct {
|
||||
fileID string
|
||||
pack Pack
|
||||
}
|
||||
func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, error)
|
||||
```
|
||||
|
||||
`resolveSource` takes `ctx`, `b`, and `ownerID` from the start even though the sticker branch
|
||||
uses none of them. Phase 5's photo branch needs all three (`GetFile`, an HTTP download,
|
||||
`UploadStickerFile{UserID}`), and it lives **inside this function**. Declaring the full
|
||||
signature now means Phase 5 adds a branch instead of rewriting every call site. There is no
|
||||
`photoRef` field: the photo path resolves to a `fileID` like every other path.
|
||||
|
||||
Its gate:
|
||||
|
||||
1. Require `msg.ReplyToMessage`; else usage error.
|
||||
2. Sticker branch — reject `IsAnimated`, `IsVideo`, **and `Type != "regular"`**
|
||||
(`models/sticker.go:16`). A mask or custom-emoji sticker is static yet invalid for a
|
||||
regular set, so the `IsAnimated || IsVideo` pair alone does not close this. The static-only
|
||||
gate belongs here, on the path that can actually receive a non-static sticker — not only in
|
||||
`resolveOwned`, which by construction only ever sees stickers already in a static pack.
|
||||
3. Otherwise (Phase 5) the photo/document branch; until then, a usage error.
|
||||
|
||||
`resolveOwned` is the single ownership gate for `/delsticker`, `/editsticker`, `/ordersticker`,
|
||||
and (Phase 5) `/setpackicon`:
|
||||
|
||||
1. Require `msg.ReplyToMessage.Sticker`; else usage error.
|
||||
2. Require a non-empty `Sticker.SetName`.
|
||||
3. Reject `IsAnimated`, `IsVideo`, or `Type != "regular"`. Static-only module; defence in
|
||||
depth. **Before** the store read, so a malformed reply costs nothing and the "rejected
|
||||
before any API call" criterion holds trivially.
|
||||
4. `getPack(ownerID)` — **one `Get`**. Absent → the caller has no pack.
|
||||
5. `ownsSet(pack, sticker.SetName)` (Phase 2) — case-insensitive against the **stored**
|
||||
`Pack.Name`. False → the sticker is not from the caller's pack.
|
||||
6. **Steps 4 and 5 must produce byte-identical reply text.** Distinct messages would let a user
|
||||
probe whether a given set belongs to someone else. Pack *management* refusals stay uniform
|
||||
even though `/newpack` deliberately discloses slug occupancy (plan's Accepted disclosure
|
||||
section) — those are different questions.
|
||||
|
||||
This replaced a `listPacks` + match design; with one pack it collapses to a single `Get` and a
|
||||
string comparison. It still compares against the stored `Pack.Name` rather than a slug
|
||||
re-derived from the live bot username — Phase 2 explains why (plan R8): a BotFather rename would
|
||||
otherwise make the user's own pack refuse as "not yours" while `/mypack` still displayed it.
|
||||
|
||||
`models.Sticker.Emoji` is a single string (`models/sticker.go:23`), so `stickerSource.emoji`
|
||||
from a replied sticker holds at most one element.
|
||||
|
||||
### `/addsticker [emoji...]`
|
||||
|
||||
Reply required. Pack from `getPack`, source from `resolveSource`. Emoji precedence: explicit
|
||||
args → the replied sticker's emoji → `defaultEmoji`.
|
||||
|
||||
**No pack yet** → "you don't have a pack yet — `/newpack <name> <title>`", zero API calls.
|
||||
This reply is deliberately **not** the uniform `resolveOwned` refusal, and the difference is
|
||||
not an oversight: `resolveOwned` is uniform because it answers a question about a set the
|
||||
caller named, which may be someone else's. `/addsticker` answers only "do *you* have a pack",
|
||||
about the caller's own state, and discloses nothing about anyone else. Being helpful here
|
||||
costs no privacy. A `Pending` record counts as no usable pack — same reply, plus the
|
||||
`/mypack` re-run hint from Phase 3.
|
||||
|
||||
Every argument is an emoji — there is no pack token to disambiguate, so a stray word is caught
|
||||
by `parseEmoji` and reported as a usage error rather than silently read as a pack name.
|
||||
|
||||
`AddStickerToSet{UserID: ownerID, Name: pack.Name, Sticker: InputSticker{Sticker: fileID, Format: "static", EmojiList: emoji}}`.
|
||||
|
||||
`UserID` is the pack owner, always the caller — the module never lets a non-owner reach this
|
||||
call. Take the per-user keylock. On success, increment `Pack.Count` and commit under
|
||||
`WithoutCancel`. `STICKERS_TOO_MUCH` maps to "your pack is full (120 stickers)".
|
||||
|
||||
**Unverified premise — settle before writing this handler.** The whole sticker-source path
|
||||
assumes `AddStickerToSet` accepts a `file_id` for a sticker that lives in a set this bot did
|
||||
not create. The Bot API documents `InputSticker.sticker` as accepting "a file_id as a String
|
||||
to send a file that already exists on the Telegram servers", and says nothing further — but
|
||||
unlike C1–C11 this was never checked against the live API, and every other API assumption in
|
||||
this plan was. If Telegram rejects cross-set reuse, this path collapses into Phase 5's
|
||||
machinery (`GetFile` → download → `UploadStickerFile` → use the returned `file_id`), which
|
||||
inverts the 4→5 dependency and is much better known before the handler is written than after.
|
||||
One live call settles it (plan R12).
|
||||
|
||||
### `/delsticker`
|
||||
|
||||
No arguments. `resolveOwned`, then `DeleteStickerFromSet{Sticker: fileID}`. On success,
|
||||
decrement `Pack.Count` (floor 0) and commit under `WithoutCancel`.
|
||||
|
||||
**Take the per-user keylock**, exactly as `/addsticker` does. Both run the same
|
||||
read-modify-write on `Pack.Count`, so locking one and not the other would be a half-measure
|
||||
that only looks safe. `/editsticker` and `/ordersticker` write nothing and take no lock.
|
||||
Under the lock, the commit uses plain `Put` — the lock is what makes the read-modify-write
|
||||
safe, so the `PutVersioned` reasoning from Phase 3 (which guards *creation*, not updates)
|
||||
does not apply.
|
||||
|
||||
Whether removing the final sticker also destroys the set is **not documented** in the Bot API
|
||||
docs or the open-source Bot API server, so the plan depends on neither answer.
|
||||
|
||||
An earlier draft probed with `GetStickerSet` afterwards and deleted the local record when the
|
||||
probe "reported not-found" — but never defined how not-found differs from a failed call, and its
|
||||
own success criterion (`FailMethod` → "record confirmed removed") specified the destructive
|
||||
reading. Under that design a 429, a DNS blip, or a SIGTERM-cancelled context during a routine
|
||||
delete would erase the only record of a live pack. With one pack per user that is strictly
|
||||
worse than it was: the user loses their pack *and* is blocked from `/newpack` until the phantom
|
||||
clears.
|
||||
|
||||
Corrected: **do not probe.** Decrement the count and stop. If the set really is gone, the next
|
||||
command returns `STICKERSET_INVALID`, and the shared handler for that (Phase 3) deletes the
|
||||
record then — a positive signal, per plan rule 4. Simpler and strictly safer.
|
||||
|
||||
**Aftermath of a `Count: 0` pack — state the recovery route.** Not probing means that if
|
||||
Telegram *did* destroy the set, the user is left holding a record for a pack that no longer
|
||||
exists. `/mypack` makes zero API calls by design, so it cannot notice; `/newpack` refuses
|
||||
because a record exists. The user is not wedged — `/delpack` calls `DeleteStickerSet`, gets
|
||||
`STICKERSET_INVALID`, and Phase 3's self-heal drops the record, freeing `/newpack` — but
|
||||
nothing in the plan told them that, and this phase is what creates the situation. So: when
|
||||
the decrement lands on 0, the reply says the pack is now empty, that Telegram may have
|
||||
removed it, and that `/delpack` clears it if `/addsticker` reports the pack is gone.
|
||||
|
||||
This is also why the success criterion below is scoped rather than absolute. `/delsticker`
|
||||
must never delete the `Pack` record on a **transient or unknown** error — that is finding R7,
|
||||
the whole reason the probe was removed. It must still delete it on a **positive**
|
||||
`STICKERSET_INVALID`, which is Phase 3's cross-command self-heal and the mechanism that
|
||||
unwedges the user. An unqualified "never deletes the record" would forbid the fix.
|
||||
|
||||
### `/editsticker <emoji...>`
|
||||
|
||||
`resolveOwned`, `parseEmoji` (at least one required — an empty `emoji_list` is invalid), then
|
||||
`SetStickerEmojiList{Sticker: fileID, EmojiList: emoji}`.
|
||||
|
||||
### `/ordersticker <position>`
|
||||
|
||||
`resolveOwned`, parse a non-negative integer, reject negatives locally. 0-based, stated in the
|
||||
usage text. Do **not** bound the upper end locally — Telegram validates against the current set
|
||||
size and a local copy would go stale. Its error goes through `replyAPIError`.
|
||||
|
||||
`SetStickerPositionInSet{Sticker: fileID, Position: pos}`.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/sticker/resolve.go`, `sticker_handlers.go`, and their tests
|
||||
- Modify: `internal/modules/sticker/sticker.go` (register four commands)
|
||||
- Reference: `internal/modules/util/handlers_test.go:108-118` — an existing test synthesizing
|
||||
`ReplyToMessage` with a `models.Sticker{FileID, FileUniqueID, SetName, Emoji}`. Exactly the
|
||||
fixture shape every test here needs.
|
||||
- Reference: `internal/testutil/update_builders.go` (`NewPrivateMessage`, `NewGroupMessage`)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `resolve.go` with both helpers and the deliberately uniform not-owned reply.
|
||||
2. The four handlers in `sticker_handlers.go`, each opening with `handlerTimeout`.
|
||||
3. Register with `Parameters` per `docs/command-parameter-conventions.md`:
|
||||
`[emoji...]`, none, `<emoji...>`, `<position>`.
|
||||
4. Tests per the Todo list.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] Settle the `file_id`-reuse premise against the live API before writing `/addsticker`
|
||||
- [x] `resolveSource` with the full `(ctx, b, ownerID, msg)` signature, sticker branch only
|
||||
- [x] `resolveSource` static-only gate: `IsAnimated`, `IsVideo`, `Type != "regular"`
|
||||
- [x] `resolveOwned` using `getPack` + `ownsSet`, with the 6-step gate and uniform refusal
|
||||
- [x] `/addsticker` with emoji precedence and `Count` increment
|
||||
- [x] `/addsticker` no-pack and pending-pack replies, zero API calls
|
||||
- [x] `/delsticker` with `Count` decrement, keylock, and **no** probe
|
||||
- [x] `/delsticker` empty-pack reply naming the `/delpack` recovery route
|
||||
- [x] `/editsticker` requiring at least one emoji
|
||||
- [x] `/ordersticker` rejecting negatives locally only
|
||||
- [x] Register all four with `Parameters` metadata
|
||||
- [x] `resolve_test.go`, `sticker_handlers_test.go`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Each command's happy path asserts the expected method in `RecordingBot.Sent()`
|
||||
- [x] Missing reply, non-sticker reply, and empty `set_name` each produce a usage error with zero API calls
|
||||
- [x] "No pack yet" and "sticker from another bot's set" produce **byte-identical** reply text, asserted by comparing the two replies to each other
|
||||
- [x] A sticker whose `SetName` differs only in case from the stored `Pack.Name` resolves successfully
|
||||
- [x] `/addsticker` with a non-emoji argument is rejected by `parseEmoji`, not silently reinterpreted
|
||||
- [x] `/ordersticker -1` rejected locally; `/ordersticker 999` reaches the API
|
||||
- [x] `/editsticker` with no emoji rejected locally
|
||||
- [x] `/delsticker` makes exactly one API call, and keeps the `Pack` record on a transient or unknown error
|
||||
- [x] `/delsticker` receiving `STICKERSET_INVALID` **does** delete the record (Phase 3 self-heal), unblocking `/newpack`
|
||||
- [x] `/addsticker` and `/delsticker` move `Count` by exactly one, floored at 0
|
||||
- [x] A `/delsticker` that lands on `Count: 0` names `/delpack` in its reply
|
||||
- [x] `/addsticker` with no pack, and with a `Pending` pack, each reply with zero API calls
|
||||
- [x] `/addsticker` on a full pack maps `STICKERS_TOO_MUCH` to the "pack is full" reply (via Phase 1 `FailMethodCode`)
|
||||
- [x] Animated, video, **and mask/custom-emoji** (`Type != "regular"`) replies rejected before any API call, on both the source and owned paths
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**The uniform-refusal requirement is easy to regress.** A later contributor improving the error
|
||||
copy could split the two messages and reintroduce the disclosure. Mitigation: the test asserts
|
||||
equality *between the two paths' replies* rather than asserting two fixed strings, so the intent
|
||||
survives a rewrite of the copy.
|
||||
|
||||
**`resolveOwned` now costs a single `Get`** — no `List`, no fan-out, no API call. This is the
|
||||
one place the one-pack revision made a correctness-critical path cheaper as well as simpler,
|
||||
and it removes the caching follow-up the multi-pack version needed.
|
||||
|
||||
**`Count` drift is user-visible but harmless.** Editing the pack through @Stickers desyncs it.
|
||||
Phase 3 refreshes it whenever a command already holds a `GetStickerSet` response, so it
|
||||
self-heals without any command paying for a lookup it did not otherwise need.
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Phase 5: Photo pipeline and pack icon"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "8h"
|
||||
dependencies: [1, 2, 3, 4]
|
||||
---
|
||||
|
||||
# Phase 5: Photo pipeline and pack icon
|
||||
|
||||
## Overview
|
||||
|
||||
Turn a replied-to photo (or image document) into a valid static sticker, and add
|
||||
`/setpackicon`, which needs the same resizing machinery at a different output size. This is
|
||||
the only phase doing network I/O and CPU work, and the only one handling the bot token.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: a replied photo or image document becomes a sticker with a 512px long edge and
|
||||
a preserved aspect ratio.
|
||||
- Functional: `/setpackicon` sets a pack's thumbnail from a sticker already in that pack.
|
||||
- Non-functional: bounded in bytes and time — it blocks every other user while it runs (C1).
|
||||
- Non-functional (**security**): the download URL embeds the bot token and must never reach a
|
||||
log, a reply, or a returned error.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Bounds
|
||||
|
||||
Plan rule 1 already puts `handlerTimeout = 10s` on every handler, which is the outer bound.
|
||||
This phase adds:
|
||||
|
||||
| Bound | Value | Why |
|
||||
|---|---|---|
|
||||
| Source file size | reject above **2 MB** before downloading | Telegram-compressed `photo` sizes are typically well under 500 KB |
|
||||
| Decoded dimensions | reject above 4096×4096 via `DecodeConfig` | Bounds peak allocation before any pixel buffer exists |
|
||||
| HTTP client | explicit per-request timeout, not `http.DefaultClient` | The library's shared client is 60s (`bot.go:17-18`) — too long to inherit |
|
||||
|
||||
Worst case is a ~10s bot-wide stall (C1). Bounded and observable, not zero.
|
||||
|
||||
### Telegram's static-sticker format
|
||||
|
||||
PNG or WEBP; **one side exactly 512px**, the other ≤512px. Pack thumbnails differ: PNG or
|
||||
WEBP, exactly **100×100**, ≤128 KB.
|
||||
|
||||
There is no documented file-size limit for static stickers — the widely-repeated 512 KB
|
||||
figure appears in no current official page (plan R4). It is a client-side ceiling only and
|
||||
must not be described as spec in code comments or user-facing text.
|
||||
|
||||
### Source selection
|
||||
|
||||
This is the photo branch of Phase 4's `resolveSource(ctx, b, ownerID, msg)`, which already
|
||||
takes every parameter this branch needs — no call site changes, and no `photoRef` type: the
|
||||
branch resolves to a `fileID` like the sticker branch, by uploading first.
|
||||
|
||||
From `msg.ReplyToMessage`:
|
||||
|
||||
- `Photo []PhotoSize` — pick the largest by `FileSize`; do not rely on Telegram's ordering.
|
||||
- `Document` — accept only `MimeType` of `image/png`, `image/jpeg`, `image/webp`. Reject
|
||||
anything else *before* downloading.
|
||||
|
||||
Reject when `FileSize > 2<<20`.
|
||||
|
||||
### Download — and the token-leak trap
|
||||
|
||||
`GetFile{FileID}` → `b.FileDownloadLink(f)` (`bot.go:180-182`) → `http.Get`.
|
||||
|
||||
`FileDownloadLink` returns `https://api.telegram.org/file/bot<TOKEN>/<path>`. **Every
|
||||
transport failure from `http.Client.Do` returns a `*url.Error` whose `Error()` embeds the
|
||||
full URL**, and `internal/modules/dispatcher.go:136-138` logs a handler's returned error
|
||||
verbatim. A timeout mid-transfer — trivially reachable — would therefore print the bot token
|
||||
to stdout, the Coolify log store, and any log shipper.
|
||||
|
||||
The earlier draft's mitigation ("log the `file_id` instead") covered only deliberate logging
|
||||
and missed this path entirely; its success criterion would have passed while the leak shipped.
|
||||
|
||||
Correct handling, per plan rule 5 — **no error from this package may escape raw**:
|
||||
|
||||
```go
|
||||
var errDownloadFailed = errors.New("sticker: download failed")
|
||||
|
||||
// ...
|
||||
if err != nil {
|
||||
log.Error("sticker download", "file_id", fileID, "reason", classify(err))
|
||||
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
|
||||
}
|
||||
```
|
||||
|
||||
The original error is discarded, never wrapped — wrapping would keep the URL reachable
|
||||
through `errors.Unwrap` and `%v`. `classify(err)` maps to a coarse label (`timeout`,
|
||||
`transport`, `status`) that cannot contain a URL.
|
||||
|
||||
Other rules:
|
||||
|
||||
- `io.LimitReader(body, 2<<20)`; never trust `Content-Length`.
|
||||
- Read fully into memory — bounded at 2 MB, so no temp files.
|
||||
|
||||
### Decode / resize / encode
|
||||
|
||||
`toStickerPNG(src []byte) ([]byte, error)`:
|
||||
|
||||
1. `image.DecodeConfig` first — reject above 4096×4096 before allocating pixels.
|
||||
2. `image.Decode` with `image/jpeg`, `image/png`, `image/gif` registered, plus
|
||||
`golang.org/x/image/webp` (read-only decoder, same module).
|
||||
3. Scale so the long edge is exactly 512 and the short edge is `round(short*512/long)`,
|
||||
clamped to ≥1. A square input yields 512×512.
|
||||
4. `draw.CatmullRom.Scale` into a fresh `*image.NRGBA` — preserves alpha.
|
||||
5. `png.Encode`. Above the 512 KB client-side ceiling, retry with
|
||||
`png.Encoder{CompressionLevel: png.BestCompression}`; if still over, step the long edge
|
||||
down (448, 384, 320). Give up after 320.
|
||||
|
||||
`toThumbnailPNG(src []byte) ([]byte, error)` runs the same pipeline to exactly 100×100,
|
||||
padding the short edge with transparency to preserve aspect ratio.
|
||||
|
||||
Both are pure functions over `[]byte` so they test without a network.
|
||||
|
||||
### Upload
|
||||
|
||||
`UploadStickerFile{UserID: ownerID, Sticker: &models.InputFileUpload{Filename: "sticker.png", Data: bytes.NewReader(png)}, StickerFormat: "static"}`
|
||||
→ use the returned `File.FileID` as `InputSticker.Sticker`.
|
||||
|
||||
Two steps, not stylistic: plan C6 shows the form builder honours `attach://` only for
|
||||
`[]models.InputSticker`, so the single `InputSticker` in `AddStickerToSetParams` cannot carry
|
||||
raw bytes; `*models.InputFileUpload` *is* handled (`build_request_form.go:87`).
|
||||
`uploadStickerFile` still takes `sticker_format` even though `createNewStickerSet` lost its
|
||||
top-level equivalent in Bot API 7.2.
|
||||
|
||||
The returned `file_id` is consumed immediately, so its undocumented validity window never
|
||||
matters. Do not restructure into upload-now-use-later.
|
||||
|
||||
### `/setpackicon`
|
||||
|
||||
No arguments; reply to a sticker in the caller's pack.
|
||||
|
||||
1. `resolveOwned` (Phase 4).
|
||||
2. `GetFile` + download that sticker's image, then `toThumbnailPNG`.
|
||||
3. `SetStickerSetThumbnail{Name: pack.Name, UserID: ownerID, Thumbnail: &models.InputFileUpload{...}, Format: "static"}`.
|
||||
|
||||
The API does accept a `file_id` string for `thumbnail` — the only documented restriction bars
|
||||
HTTP URLs for animated/video. The reason to resize is the documented 100×100 requirement,
|
||||
which a 512px sticker's `file_id` does not meet. Confirm in Phase 6's smoke test; if a raw
|
||||
`file_id` is accepted and auto-resized, this collapses to one call.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `go.mod`, `go.sum` — add `golang.org/x/image`
|
||||
- Create: `internal/modules/sticker/download.go`, `image.go`, `setpackicon.go`,
|
||||
`download_test.go`, `image_test.go`
|
||||
- Modify: `internal/modules/sticker/resolve.go` (photo branch), `sticker_handlers.go`
|
||||
(`/addsticker` photo path), `pack_handlers.go` (`/newpack` photo path), `sticker.go`
|
||||
- Reference: `go-telegram/bot@v1.20.0` `bot.go:180-182`, `build_request_form.go:87,105`
|
||||
- Reference: `internal/modules/dispatcher.go:136-138` (the log path the sentinel protects)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `go get golang.org/x/image`; confirm a direct require and a clean `go mod tidy`.
|
||||
2. `download.go` — bounded fetch, own client timeout, sentinel error conversion.
|
||||
3. `image.go` — `toStickerPNG`, `toThumbnailPNG`.
|
||||
4. Wire the photo branch into `resolveSource`, then `/addsticker` and `/newpack`.
|
||||
5. `setpackicon.go` + registration.
|
||||
6. Tests per the Todo list.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] Add `golang.org/x/image`; verify `go mod tidy` produces no diff
|
||||
- [x] `download.go` with 2 MB `LimitReader`, own client timeout, sentinel conversion
|
||||
- [x] `classify(err)` returning a coarse label that cannot contain a URL
|
||||
- [x] `toStickerPNG` with DecodeConfig guard, CatmullRom scale, PNG size ladder
|
||||
- [x] `toThumbnailPNG` at exactly 100×100 with transparent padding
|
||||
- [x] Photo/document source selection with mime allowlist and 2 MB pre-check
|
||||
- [x] Wire photo branch into `/addsticker` and `/newpack`
|
||||
- [x] `/setpackicon` handler and registration
|
||||
- [x] `image_test.go` with in-test generated fixtures (no committed binaries)
|
||||
- [x] `download_test.go` asserting no token or URL in any returned error
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] 1024×512 → 512×256; 300×900 → 171×512; 512×512 → 512×512
|
||||
- [x] 1×5000 extreme aspect: short edge clamped to ≥1, no panic, no zero-dimension image
|
||||
- [x] Alpha channel preserved through the resize
|
||||
- [x] Source above 2 MB rejected with zero HTTP requests made
|
||||
- [x] Decoded dimensions above 4096×4096 rejected before pixel allocation
|
||||
- [x] Unsupported document mime rejected before download
|
||||
- [x] `toThumbnailPNG` output is exactly 100×100
|
||||
- [x] **A forced transport failure against an `httptest` server yields an error whose text contains neither `"bot"` nor the URL** — asserted, not assumed
|
||||
- [x] `go mod tidy && git diff --exit-code go.mod go.sum` clean
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**R1 — bot-wide stall.** Under C1 every photo request blocks all users for up to
|
||||
`handlerTimeout`. Tight bounds cap the damage but do not remove it; a user in a loop can keep
|
||||
the bot substantially stalled.
|
||||
|
||||
- Signal: reply latency for unrelated commands spikes with image traffic.
|
||||
- Response, in order: (a) lower `handlerTimeout` and the 2 MB cap; (b) offload the pipeline
|
||||
to a detached goroutine that acks immediately and replies on completion, mirroring
|
||||
`dispatcher.go:80-84`; (c) if neither suffices, revisit the Public visibility decision with
|
||||
the user — their call, not a unilateral change.
|
||||
- **If (b) is ever taken, C1's single-in-flight guarantee disappears**, and two things become
|
||||
mandatory together: a package-level semaphore around image decoding, and a real look at the
|
||||
`/newpack` quota check, which becomes genuinely racy rather than merely lock-protected. The
|
||||
two are linked deliberately so neither is done without the other.
|
||||
|
||||
**Untrusted image decoding.** Bounded by size and dimension checks; Go's decoders are
|
||||
memory-safe. Peak allocation is ~64 MB per conversion at the 4096² cap, and C1 guarantees one
|
||||
at a time. Phase 1's panic barrier is the backstop for a decoder panic — but it is a backstop,
|
||||
not a licence to skip the dimension guard.
|
||||
|
||||
**New dependency.** `golang.org/x/image` is the only one in the plan and the repo's first
|
||||
*direct* `golang.org/x/*` requirement. Confined to `image.go`. If resampling disappoints,
|
||||
swapping `CatmullRom` for `ApproxBiLinear` is one line (plan R9).
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
phase: 6
|
||||
title: "Phase 6: Wiring, menu, docs"
|
||||
status: partial
|
||||
priority: P2
|
||||
effort: "4h"
|
||||
dependencies: [1, 2, 3, 4, 5]
|
||||
---
|
||||
|
||||
# Phase 6: Wiring, menu, docs
|
||||
|
||||
## Overview
|
||||
|
||||
Register the module, make enabling it an explicit operator decision, and bring the user-facing
|
||||
surfaces named in `AGENTS.md` § "Command Changes" into line. Nothing from Phases 2–5 is
|
||||
reachable by a user until this phase lands.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: the module is enabled only by an explicit `MODULES` entry.
|
||||
- Functional: all nine commands appear in `/help` and the native menu with correct metadata.
|
||||
- Non-functional: `/help` stays under the 4096-rune ceiling the test suite enforces.
|
||||
- Non-functional: no stats migration — nothing is renamed or deleted.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Enablement must come before registration
|
||||
|
||||
`internal/modules/registry.go:107-116` expands an empty `MODULES` to **every** registered
|
||||
factory, and its own comment calls that the documented contract. The repo ships
|
||||
`.env.example:16` as `MODULES=` (empty) and `compose.yml:14` documents "empty = all modules".
|
||||
|
||||
So adding `"sticker": sticker.New` to `factories()` **is** the enablement: a public,
|
||||
write-capable module would go live on the next deploy with no operator decision. An earlier
|
||||
draft claimed the opposite in three places — goal, requirement, and a success criterion a
|
||||
reviewer would have ticked without testing.
|
||||
|
||||
Ordered fix, per the user's decision:
|
||||
|
||||
1. **First**, set `MODULES` explicitly in the deployed environment to the current eleven
|
||||
modules, and verify the bot restarts with an unchanged command set:
|
||||
`util,misc,amlich,monkeyd,wordle,loldle,lol,stock,gold,coin,stats`
|
||||
2. **Then** add the `factories()` entry and merge.
|
||||
3. Add `sticker` to `MODULES` when the operator chooses to turn it on.
|
||||
|
||||
Only after step 1 is "remove `sticker` from `MODULES`" a genuine zero-deploy rollback. Before
|
||||
it, that rollback means enumerating eleven module names into an empty variable under pressure.
|
||||
|
||||
Update `.env.example` with the explicit list and a comment stating why, so a fresh clone does
|
||||
not reintroduce the empty-means-everything trap.
|
||||
|
||||
### Registration
|
||||
|
||||
One line in `factories()` (`cmd/server/main.go:83`). Plain string key, matching `util`, `misc`,
|
||||
and `gold`; the `CollectionName` constant form in `lol`/`coin`/`stock` exists because those
|
||||
packages reuse the name elsewhere, which this one does not.
|
||||
|
||||
`Build` validates names against `^[a-z0-9_]{1,32}$` (`validate.go:10`) and rejects duplicates
|
||||
(`registry.go:173`). All nine were verified free against the current registry; re-run registry
|
||||
tests to catch later additions. Note `mypack` replaced `packlist` in the one-pack revision —
|
||||
re-verify that name specifically, since it was not part of the original conflict check.
|
||||
|
||||
### The `/help` rune budget
|
||||
|
||||
`cmd/server/command_menu_test.go:110-113` renders the full `/help` body and `t.Fatalf`s above
|
||||
`telegramMessageMaxRunesForTest = 4096` (`:116-119`).
|
||||
|
||||
Measured at HEAD: **3212 runes, 45 public commands, 11 modules — 884 runes of headroom.**
|
||||
|
||||
The one-pack revision helps here. Each help line is
|
||||
`InvocationSentence() + " " + SummarySentence()` (`internal/modules/command_presentation.go:16-24`),
|
||||
so `Parameters` counts against the budget — and four commands lost their `<pack>` token:
|
||||
|
||||
| Command | Was | Now |
|
||||
|---|---|---|
|
||||
| `/addsticker` | `<pack> [emoji...]` | `[emoji...]` |
|
||||
| `/renamepack` | `<pack> <title...>` | `<title...>` |
|
||||
| `/delpack` | `<pack>` | — |
|
||||
| `/packlist` → `/mypack` | — | — |
|
||||
|
||||
That is roughly 25 runes recovered across the module, leaving about **98 runes per command
|
||||
line** including the module header rather than ~85. Still not generous: `/ordersticker <position>.`
|
||||
alone is 24 runes, leaving ~74 for its description.
|
||||
|
||||
Write the nine descriptions against that budget *before* wiring, then re-measure — the figure
|
||||
above is derived, not measured post-change. If they do not fit, decide then whether `/help`
|
||||
needs pagination; that is a separate change, and it must not be "solved" by trimming other
|
||||
modules' descriptions.
|
||||
|
||||
### Test impact — which tests, and which only look related
|
||||
|
||||
| Test | Uses real `factories()`? | Action |
|
||||
|---|---|---|
|
||||
| `main_test.go:111` `TestFactoriesIncludesExpectedModules` | No — builds only `{"gold","coin"}` | **No change needed** |
|
||||
| `command_menu_test.go:19` `TestBotCommandMenu_...ModuleOrder` | No — synthetic `alpha`/`beta` | **No change needed** |
|
||||
| `command_menu_test.go:54` `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` | **Yes** — `modules.Build(nil, factories(), …)` | **Add all nine to `expectedParameters`**; also enforces ≤256-rune descriptions, no `Eg:`, no newlines, and the 4096-rune `/help` ceiling |
|
||||
| `command_menu_test.go:121` `TestBotCommandMenu_StockDividendContracts` | Stock-specific | No change |
|
||||
|
||||
`expectedParameters` entries for the nine: `newpack` → `<pack> <title...>`, `addsticker` →
|
||||
`[emoji...]`, `delsticker` → ``, `editsticker` → `<emoji...>`, `ordersticker` → `<position>`,
|
||||
`setpackicon` → ``, `renamepack` → `<title...>`, `delpack` → ``, `mypack` → ``.
|
||||
|
||||
**`<name...>` is not yet a documented form.** `docs/command-parameter-conventions.md:15-20`
|
||||
defines `<name>`, `<name,...>`, `[name]`, and `[name...]` — required *remaining text* appears
|
||||
nowhere, and no existing command uses it (`rg "Parameters:"` across `internal/` confirms).
|
||||
Three of the nine (`<title...>` twice, `<emoji...>`) need it. Add the row
|
||||
`| Required remaining text | \`<name...>\` | \`<title...>\` |` to that table and an example
|
||||
line, in the same change that registers the commands — the conventions doc is the authority
|
||||
these registrations are validated against, so shipping an undocumented form silently
|
||||
demotes it.
|
||||
|
||||
### README and docs
|
||||
|
||||
Module table row listing the nine commands, then a `### Sticker packs` section: **one pack per
|
||||
user**; the pack is created on behalf of the calling user; the bot manages only packs it
|
||||
created; the slug is chosen once at `/newpack` and fixes a permanent share link that
|
||||
`/renamepack` cannot change; static stickers and photos only; the 120-stickers-per-pack cap;
|
||||
anonymous group admins are not supported and why.
|
||||
|
||||
Note the relationship to `/stickerid` in `util` — it stays put. It is a private debug helper for
|
||||
reading a `file_id`, not pack management.
|
||||
|
||||
`docs/sticker-packs.md` carries: the full command reference with `Parameters` matching handler
|
||||
usage text exactly (`docs/command-parameter-conventions.md` § Change Checklist); slug rules and
|
||||
the permanence of the resulting URL; the image contract (512px long edge, PNG; thumbnails
|
||||
100×100); what is deliberately absent and why (usage-statistics commands — no Bot API support;
|
||||
animated/video/emoji packs — out of scope; multiple packs per user — see plan R10;
|
||||
conversational flow — no message hook at `internal/modules/dispatcher.go:66`); the accepted
|
||||
slug-occupancy disclosure; and the `Count` drift note.
|
||||
|
||||
### Stats compatibility
|
||||
|
||||
No command is renamed or deleted **in the shipped bot** — `/packlist` never existed outside this
|
||||
plan, so its replacement by `/mypack` needs no migration. `AGENTS.md` § "Stats Compatibility"
|
||||
governs renames of live commands and does not apply. New commands accrue stats through the
|
||||
dispatcher hook (`dispatcher.go:80-84`).
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: deployed environment `MODULES` (**before** the code change), `.env.example`
|
||||
- Modify: `cmd/server/main.go` (factory entry + import)
|
||||
- Modify: `cmd/server/command_menu_test.go` (`expectedParameters`, +9)
|
||||
- Modify: `README.md`
|
||||
- Create: `docs/sticker-packs.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Set `MODULES` explicitly in the deployed environment; verify an unchanged command set.
|
||||
2. Draft the nine descriptions against the ~98-rune budget; measure `RenderHelp` locally.
|
||||
3. Add the factory entry and import.
|
||||
4. Update `expectedParameters`; run `go test ./cmd/server/...`.
|
||||
5. README row + section; `docs/sticker-packs.md`.
|
||||
6. Full gate, then the manual smoke sequence.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Set `MODULES` explicitly in the deployed environment and verify
|
||||
- [x] Update `.env.example` with the explicit list and a why-comment
|
||||
- [x] Confirm `mypack` is free in the registry alongside the other eight
|
||||
- [x] Draft nine descriptions within the measured budget and re-measure `RenderHelp`
|
||||
- [x] Add `"sticker": sticker.New` to `factories()`
|
||||
- [x] Add nine entries to `expectedParameters`
|
||||
- [x] Add the `<name...>` row + example to `docs/command-parameter-conventions.md`
|
||||
- [x] README module-table row + `### Sticker packs` section
|
||||
- [x] `docs/sticker-packs.md`
|
||||
- [x] Full validation gate
|
||||
- [ ] Manual smoke sequence against a real token
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `gofmt -l .` empty; `go vet ./...` clean; `go test ./...` passes; `golangci-lint run` clean
|
||||
- [x] `RenderHelp` stays under 4096 runes with all nine commands registered
|
||||
- [ ] With `MODULES` unset in a scratch environment, the module still loads — confirming C10 is understood rather than assumed away
|
||||
- [ ] With the explicit `MODULES` list and no `sticker` entry, none of the nine commands register
|
||||
- [ ] Smoke: reply to a sticker with `/newpack smoke_pack Smoke Pack`; the returned link opens
|
||||
- [ ] Smoke: a second `/newpack` is refused and names the existing pack
|
||||
- [ ] Smoke: reply to a photo with `/addsticker 😂` — no pack named — and the sticker renders undistorted
|
||||
- [ ] Smoke: `/mypack`, `/renamepack New Title`, `/setpackicon`, `/ordersticker 0` all succeed
|
||||
- [ ] Smoke: `/renamepack` leaves the share link working and unchanged
|
||||
- [ ] Smoke: a **second** account replying to the first account's sticker with `/delsticker` is refused
|
||||
- [ ] Smoke: an anonymous group admin is refused with the explanatory message
|
||||
- [ ] Smoke: `/delpack` confirm prompt shows the title, sticker count, and link before confirming
|
||||
- [ ] Smoke: `/delpack` → confirm → link 404s; a second press reports already-used; `/newpack` then works again
|
||||
- [ ] Smoke: **after deleting, attempt `/newpack` with the same slug** — this settles plan R11. Record the outcome in `docs/sticker-packs.md` either way, and if the slug is reserved, add that to `/delpack`'s confirm text
|
||||
- [ ] Smoke: `/renamepack` reply names the delete-and-recreate route
|
||||
- [ ] Smoke: observed error strings for an occupied slug and a full pack match `replyAPIError`, or the table is corrected
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**The error-code table is unverified until this phase.** Only three MTProto codes are rewritten
|
||||
into prose by the Bot API server; the rest were inferred from the open-source server's rewrite
|
||||
table, not a live reproduction (plan R3). The smoke sequence deliberately includes an
|
||||
occupied-slug and a full-pack case. A mismatch is a polish gap, not a blocker — the generic
|
||||
fallback means users see a sane message either way.
|
||||
|
||||
**Manual smoke is the only Telegram-side coverage.** Every automated test uses `RecordingBot`,
|
||||
which never contacts Telegram. CI cannot catch a wrong parameter name or a rejected image
|
||||
format. The smoke sequence is mandatory before announcing the feature.
|
||||
|
||||
**Enablement ordering is a process risk, not a code one.** If step 1 is skipped and the factory
|
||||
entry merges first, the module goes live unannounced. Signal: the deployed bot answers
|
||||
`/mypack` before anyone enabled it. Response: set `MODULES` immediately; the module is otherwise
|
||||
harmless until someone runs a command.
|
||||
@@ -0,0 +1,588 @@
|
||||
---
|
||||
title: "Sticker packs module"
|
||||
description: "internal/modules/sticker — public, one-pack-per-user Telegram sticker set management via single-shot reply commands using @Stickers command names"
|
||||
status: partial
|
||||
priority: P2
|
||||
effort: ""
|
||||
tags: ["sticker", "telegram-bot", "module"]
|
||||
created: 2026-08-24
|
||||
branch: main
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# Sticker packs module
|
||||
|
||||
## Overview
|
||||
|
||||
New module `internal/modules/sticker` letting **any** user create and manage **one**
|
||||
personal Telegram sticker pack through the bot. The pack is created on behalf of the calling
|
||||
user (`user_id`), named `<slug>_by_<bot_username>`, and stays bot-manageable because the bot
|
||||
created it.
|
||||
|
||||
One pack per user is the central simplification: no command except `/newpack` takes a pack
|
||||
argument, because there is only ever one pack to act on.
|
||||
|
||||
Command names mirror @Stickers (`/newpack`, `/addsticker`, …) but each command is
|
||||
**single-shot**: one message carrying its arguments, optionally replying to a sticker or
|
||||
photo. Commands are **unprefixed**, like the `misc` module (`/ff`, `/random`).
|
||||
|
||||
Phase 1 fixes two shared-code gaps this module would otherwise expose. They are
|
||||
prerequisites, not incidental work.
|
||||
|
||||
## Goals
|
||||
|
||||
| # | Goal | Priority |
|
||||
|---|------|----------|
|
||||
| 1 | Any user can create and fill their personal sticker pack without leaving the chat | P1 |
|
||||
| 2 | No command but `/newpack` requires naming a pack | P1 |
|
||||
| 3 | Ownership is enforced structurally — a user can never mutate another's pack | P1 |
|
||||
| 4 | No sticker command can stall the bot for other users beyond a bounded deadline | P1 |
|
||||
| 5 | A partial failure never permanently strands a user's pack | P1 |
|
||||
| 6 | Command names and semantics recognisable to @Stickers users | P2 |
|
||||
| 7 | Enabling the module is an explicit operator decision | P2 |
|
||||
|
||||
## Accepted scope
|
||||
|
||||
| Decision | Value |
|
||||
|---|---|
|
||||
| Visibility | `VisibilityPublic` — every user manages their own packs |
|
||||
| Pack model | **One pack per user.** The slug is chosen at creation and never used as an argument again |
|
||||
| Inputs | Existing static stickers (reply) + photos/image documents (reply) |
|
||||
| Interaction | Single-shot reply + args; no conversation state, no `/cancel` |
|
||||
| Naming | @Stickers command names, no module prefix |
|
||||
|
||||
## Command surface
|
||||
|
||||
All `VisibilityPublic`. `Parameters` follows `docs/command-parameter-conventions.md`.
|
||||
|
||||
| Command | Parameters | Reply required | API calls |
|
||||
|---|---|---|---|
|
||||
| `/newpack` | `<pack> <title...>` | yes (sticker/photo) | `GetStickerSet`, `UploadStickerFile`*, `CreateNewStickerSet` |
|
||||
| `/addsticker` | `[emoji...]` | yes (sticker/photo) | `UploadStickerFile`*, `AddStickerToSet` |
|
||||
| `/delsticker` | — | yes (sticker in own pack) | `DeleteStickerFromSet` |
|
||||
| `/editsticker` | `<emoji...>` | yes (sticker in own pack) | `SetStickerEmojiList` |
|
||||
| `/ordersticker` | `<position>` | yes (sticker in own pack) | `SetStickerPositionInSet` |
|
||||
| `/setpackicon` | — | yes (sticker in own pack) | `GetFile`, `SetStickerSetThumbnail` |
|
||||
| `/renamepack` | `<title...>` | no | `SetStickerSetTitle` |
|
||||
| `/delpack` | — | no (inline confirm) | `DeleteStickerSet` |
|
||||
| `/mypack` | — | no | **none** — count comes from the store |
|
||||
|
||||
`*` only on the photo path.
|
||||
|
||||
### Slug is a name, not an address
|
||||
|
||||
`<pack>` survives on `/newpack` alone, where it fixes the permanent share URL
|
||||
`t.me/addstickers/<slug>_by_<bot>`. Telegram has no rename-short-name method, so that choice
|
||||
is unfixable afterwards — which is exactly why it stays user-chosen rather than derived from
|
||||
a user ID (which would publish the owner's numeric Telegram ID forever) or generated opaquely.
|
||||
|
||||
Every other command resolves the caller's single pack from the store, or from the replied
|
||||
sticker's `set_name` matched against it. This supersedes the earlier multi-pack design in
|
||||
which `<pack>` was an argument to `/addsticker`, `/renamepack`, and `/delpack`.
|
||||
|
||||
### Dropped from @Stickers
|
||||
|
||||
`/stats`, `/top`, `/packstats`, `/packtop`, `/topbypack`, `/packusagetop` report sticker
|
||||
**usage counts**, which the Bot API does not expose. `/stats` is also already owned by the
|
||||
`stats` module. `/newanimated`, `/newvideo`, `/newemojipack`, `/newmasks` are outside the
|
||||
accepted static-only scope. `/cancel` is meaningless without conversation state.
|
||||
|
||||
## Architecture constraints (verified against this repo and the live API)
|
||||
|
||||
Each was checked against source, not assumed. C1–C8 survived adversarial review; C5 was
|
||||
corrected.
|
||||
|
||||
### C1 — Handlers are globally serialized
|
||||
|
||||
`internal/telegram/client.go:27` passes `bot.WithNotAsyncHandlers()`, and the library's
|
||||
`defaultWorkers = 1` (`bot.go:20`) with no `WithWorkers` override. `process_update.go:26-28`
|
||||
runs the handler inline. One slow handler stalls **every** user.
|
||||
|
||||
Caveat found in review: "one update at a time" is not "one goroutine". The cron scheduler
|
||||
(`cmd/server/main.go:163`) and the detached per-command stats hook
|
||||
(`internal/modules/dispatcher.go:80-84`) both run concurrently with handlers. Neither
|
||||
touches pack state today, but the per-user keylock is therefore **not** redundant.
|
||||
|
||||
### C2 — No per-update deadline, and the library's own ceiling is 60s
|
||||
|
||||
Handler ctx is `rootCtx` (`cmd/server/main.go:107,214`), which has no deadline, so
|
||||
`chathelper.FetchContext` (`chathelper.go:107-113`) returns a bare `WithCancel` that bounds
|
||||
nothing. The only remaining ceiling is the library's shared
|
||||
`http.Client{Timeout: time.Minute}` (`bot.go:17-18,75-77`).
|
||||
|
||||
Consequence: **every** handler needs its own explicit deadline, not just the photo path.
|
||||
Ten sequential API calls under a 60s per-call ceiling is a ~10-minute bot-wide freeze.
|
||||
|
||||
### C3 — Callback data caps at 64 bytes
|
||||
|
||||
`internal/modules/stock/pending_dividend.go:16-17` enforces `maxDividendCallbackBytes = 64`
|
||||
against Telegram's limit. `/delpack` carries an opaque pending-action id, not a slug, so the
|
||||
budget is comfortable.
|
||||
|
||||
### C4 — Callback prefix conflicts are checked bidirectionally
|
||||
|
||||
`internal/modules/registry.go:216-219`. `sticker_pack:` does not overlap `stock_div:`, the
|
||||
only existing prefix.
|
||||
|
||||
### C5 — The API cannot prove ownership, so the bot must record intent before acting
|
||||
|
||||
`getStickerSet` returns only `name`, `title`, `sticker_type`, `stickers`, `thumbnail`
|
||||
(`models/sticker_set.go:4-10`) — no owner field.
|
||||
|
||||
The earlier draft concluded "therefore orphaned sets can never be adopted". Review showed
|
||||
that does not follow: the bot does not need the API to name the owner, it needs **its own
|
||||
record of who asked for that name**. Since only this bot can create `*_by_<bot_username>`
|
||||
sets, a write-ahead intent record makes an existing set attributable. See R2 and Phase 3.
|
||||
|
||||
### C6 — Raw bytes cannot ride on `AddStickerToSet`
|
||||
|
||||
`build_request_form.go:105` handles `attach://` only for `[]models.InputSticker`. The single
|
||||
`InputSticker` in `AddStickerToSetParams` (`methods_params.go:905-909`) falls through to
|
||||
`addFormFieldDefault`, and `StickerAttachment` is `json:"-"` (`models/sticker.go:40`), so it
|
||||
is silently dropped. Photo path must be `UploadStickerFile` (which accepts
|
||||
`*models.InputFileUpload`, `build_request_form.go:87`) → use the returned `File.FileID`.
|
||||
|
||||
### C7 — Library is current for stickers
|
||||
|
||||
Bot API is at 10.3 (2026-08-24); last sticker changes were Bot API 7.2 (2024-03-31).
|
||||
`v1.20.0` has `InputSticker.Format`, no top-level `sticker_format` on
|
||||
`CreateNewStickerSetParams`, and `ReplaceStickerInSet`. No known gap.
|
||||
|
||||
### C8 — Telegram limits (confirmed against official docs)
|
||||
|
||||
| Limit | Value |
|
||||
|---|---|
|
||||
| Set name | 1–64 chars, letters/digits/underscore, begins with a letter, no consecutive underscores, ends `_by_<bot_username>` (case-insensitive) |
|
||||
| Static sticker image | PNG or WEBP; one side **exactly** 512px, other ≤512px |
|
||||
| Static sticker file size | **Not documented.** The widely-repeated 512 KB figure appears in no current official page (R4) |
|
||||
| Set thumbnail | PNG/WEBP, exactly 100×100, ≤128 KB |
|
||||
| Stickers per set | 120 regular/mask, 200 custom emoji |
|
||||
| Emoji per sticker | 1–20 |
|
||||
| Set title | 1–64 chars |
|
||||
| Sets per bot | **Not documented** — no known ceiling |
|
||||
|
||||
### C9 — There is no panic barrier on the update path
|
||||
|
||||
`rg "recover()"` finds four sites: `testutil/mongotest`, `server/log_middleware.go:50`,
|
||||
`monkeyd/export_job.go:52`, `cron/scheduler.go:67`. **None on the command or callback path.**
|
||||
`internal/modules/dispatcher.go:167` carries a stale comment promising "our `recover()` in
|
||||
webhook.go"; `internal/telegram/webhook.go` contains only `DeleteWebhook`.
|
||||
|
||||
With C1, a panic in any handler terminates the process. Phase 1 closes this.
|
||||
|
||||
### C10 — `MODULES` is not opt-in
|
||||
|
||||
`internal/modules/registry.go:107-116` expands an empty list to every registered factory,
|
||||
and its own comment calls that the documented contract (`.env.example:16` ships `MODULES=`
|
||||
empty; `compose.yml:14` says "empty = all modules"). Adding a `factories()` entry **is** the
|
||||
enablement. Phase 6 sets `MODULES` explicitly before merging.
|
||||
|
||||
### C11 — `RecordingBot` cannot return structured results
|
||||
|
||||
`internal/testutil/recording_bot.go:178-195` answers every non-message-producing method with
|
||||
`{"ok":true,"result":true}`. `GetStickerSet`, `GetFile`, and `UploadStickerFile` decode into
|
||||
structs, so under the current harness they can only ever **error**. `FailMethod`
|
||||
(`:99-112`) emits no `error_code`, so library errors in tests never take the
|
||||
`ErrorBadRequest` shape production emits. Phase 1 extends the harness.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | [Phase 1: Shared prerequisites](./phase-01-shared-prerequisites.md) | Done |
|
||||
| 2 | [Phase 2: Store, set names, emoji parsing](./phase-02-store-setname-emoji.md) | Done |
|
||||
| 3 | [Phase 3: Pack lifecycle commands](./phase-03-pack-lifecycle.md) | Done |
|
||||
| 4 | [Phase 4: Sticker commands (reply path)](./phase-04-sticker-commands.md) | Done |
|
||||
| 5 | [Phase 5: Photo pipeline and pack icon](./phase-05-photo-pipeline.md) | Done |
|
||||
| 6 | [Phase 6: Wiring, menu, docs](./phase-06-wiring-docs.md) | Partial — code + docs done; live smoke and deployed MODULES pending |
|
||||
|
||||
## Dependencies
|
||||
|
||||
Phase 1 blocks everything (shared code + test harness). Phase 2 blocks 3, 4, 5. Phase 3
|
||||
blocks 4 and 5. Phase 5 depends on Phase 4's `/addsticker` handler. Phase 6 last. No
|
||||
cross-plan dependencies — the only other plan is completed and touches disjoint files.
|
||||
|
||||
## Cross-cutting rules
|
||||
|
||||
These apply to every handler in Phases 3–5. Stated once here rather than repeated.
|
||||
|
||||
1. **Explicit deadline.** Every handler opens with
|
||||
`ctx, cancel := context.WithTimeout(ctx, handlerTimeout)` (`handlerTimeout = 10s`,
|
||||
package constant). Required by C2 — nothing else bounds a call.
|
||||
2. **Durable writes survive shutdown.** Store writes that commit a completed Telegram-side
|
||||
action use `context.WithoutCancel(ctx)` plus a short timeout, mirroring the existing
|
||||
idiom at `dispatcher.go:78-84`. `rootCtx` is cancelled by SIGTERM mid-handler, so a
|
||||
plain `ctx` write fails on every deploy (R2).
|
||||
3. **One sender helper.** `senderID(msg) (int64, error)` rejects a nil `From`, `From.IsBot`,
|
||||
and any message carrying `SenderChat`. Anonymous group admins share a single
|
||||
`GroupAnonymousBot` id, so without this all anonymous admins across all groups share one
|
||||
pack namespace and one quota (R6).
|
||||
4. **Positive error classification only.** `isStickerSetMissing(err)` is
|
||||
`errors.Is(err, bot.ErrorBadRequest) && strings.Contains(err.Error(), "STICKERSET_INVALID")`.
|
||||
Any other error is "unknown — abort with no side effects". Never infer "absent" from a
|
||||
generic failure (R3, R7).
|
||||
5. **Errors from the download path never escape raw.** They are converted to a sentinel at
|
||||
the boundary, discarding the original (R5).
|
||||
|
||||
## New dependency
|
||||
|
||||
`golang.org/x/image` — for `draw.CatmullRom` resampling. Stdlib decodes JPEG/PNG and encodes
|
||||
PNG but ships no scaler, and stickers need an exact 512px long edge. Note it becomes the
|
||||
repo's first *direct* `golang.org/x/*` requirement; all current ones are indirect.
|
||||
|
||||
## Abuse surface
|
||||
|
||||
Public module creating durable Telegram-side objects on a single-threaded dispatcher (C1):
|
||||
|
||||
- One pack per user, enforced by a create-only write before `CreateNewStickerSet`. This is
|
||||
the quota; there is no separate counter to keep.
|
||||
- `handlerTimeout = 10s` on every handler — the primary bound (C2).
|
||||
- `/mypack` makes **zero** API calls; the count lives on the `Pack` record. A single `Get`,
|
||||
no `List`, no per-pack fan-out.
|
||||
- Photo source rejected above 2 MB; decoded dimensions capped at 4096×4096.
|
||||
- Slug alphabet `^[a-z][a-z0-9_]{2,39}$`, no `__`, no trailing `_`.
|
||||
- `internal/keylock` per-user serialization — genuinely load-bearing, not decorative,
|
||||
because crons and the detached stats hook run concurrently with handlers (C1 caveat).
|
||||
- Anonymous/bot senders refused outright (cross-cutting rule 3).
|
||||
|
||||
## Risks
|
||||
|
||||
| # | Risk | Signal it broke | Response |
|
||||
|---|---|---|---|
|
||||
| R1 | A handler stalls the bot for all users (C1) | Reply latency spikes for unrelated commands | `handlerTimeout` caps it at 10s; if still felt, lower it, then consider offloading the photo path (Phase 5 R1) |
|
||||
| R11 | A deleted slug may not be reclaimable | `/newpack <old-slug>` after `/delpack` reports the slug taken | Unknown at decision time; official docs are silent and community reports lean toward short names staying reserved. Does **not** block the URL-change path, which needs a *different* name. Settled empirically in Phase 6 smoke; if reserved, say so in `/delpack`'s confirm text |
|
||||
| R10 | A user wants two packs and cannot have one | Requests for a second pack | Accepted by design. Reversing it means restoring `<pack>` arguments across four commands — a deliberate, not incidental, change |
|
||||
| R2 | Partial failure strands a pack | User reports a slug reported taken that `/mypack` does not show | Write-ahead intent + `WithoutCancel` commits (Phase 3). Reverse gaps for `/delpack` and `/renamepack` documented in Phase 3 |
|
||||
| R3 | Error-code matching drifts | Users see the generic reply where a specific one was expected | Match MTProto **codes**, never human text; confirm empirically in Phase 6 smoke |
|
||||
| R4 | The 512 KB static-sticker ceiling may not be real | Uploads succeed above it, or fail below it | Client-side ceiling only; never stated as spec in user-facing text |
|
||||
| R5 | Bot token leaks through a transport error | Any log line containing `api.telegram.org/file/bot` | Sentinel conversion at the download boundary + a test asserting the error text is clean (Phase 5) |
|
||||
| R6 | Ownership collapses for anonymous senders | Two users see each other's packs | Cross-cutting rule 3 refuses them before any store access |
|
||||
| R7 | A transient error deletes a live pack's record | `/mypack` reports no pack though the user can still open theirs via link | Rule 4 — destructive store deletes require positive `STICKERSET_INVALID` |
|
||||
| R8 | Bot username changes in BotFather | Every pack refuses as "not yours" after restart | Ownership matches stored `Pack.Name` against `Sticker.SetName`; username only builds *new* names (Phase 2) |
|
||||
| R9 | `golang.org/x/image` resampling disappoints | Visibly soft or aliased stickers in smoke | Swap `CatmullRom` for `ApproxBiLinear`; one line, one file |
|
||||
| R12 | `AddStickerToSet` may reject a `file_id` from a set this bot did not create | `/addsticker` on a sticker from any other pack fails while the photo path works | The one API assumption in this plan never checked against the live API — the docs allow a `file_id` in `InputSticker.sticker` but are silent on provenance. Settle with one live call before writing Phase 4's handler. If rejected, `/addsticker`'s sticker path routes through Phase 5's `GetFile` → download → `UploadStickerFile`, which makes Phase 4 depend on Phase 5 rather than the reverse — cheap to know first, expensive to discover after |
|
||||
|
||||
## Accepted disclosure
|
||||
|
||||
`/newpack` answers "is this slug taken?" for any slug, which reveals that *some* user of this
|
||||
bot owns it. This is accepted, not solved: `t.me/addstickers/<slug>_by_<bot>` is publicly
|
||||
probeable without the bot, so the command adds no information an attacker lacks. The earlier
|
||||
draft claimed no disclosure while shipping this probe — the claim was wrong and is removed.
|
||||
Pack *management* refusals remain deliberately uniform (Phase 4), because those would
|
||||
otherwise disclose which of the caller's own guesses correspond to real packs.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] A non-admin user can reply to a sticker with `/newpack mypack My Pack` and receive a working `t.me/addstickers/mypack_by_<bot>` link
|
||||
- [ ] `/addsticker 😂` on a photo produces a valid static sticker with a 512px long edge and correct aspect ratio, with no pack named
|
||||
- [ ] Managing a pack the caller does not own fails with an ownership error and makes zero API calls
|
||||
- [ ] The not-owned reply text is byte-identical whether the set is another user's or another bot's
|
||||
- [ ] `/mypack` shows slug, title, count, and link for the caller's own pack, making no API calls
|
||||
- [ ] A second `/newpack` while a pack exists is refused, naming the existing pack and pointing at `/delpack`
|
||||
- [ ] `/delpack` takes no argument and requires inline confirmation bound to invoker, chat, message, and a TTL
|
||||
- [ ] `/delpack`'s confirm prompt states the title, sticker count, link, and permanence before the tap
|
||||
- [ ] `/renamepack`'s reply names the `/delpack` + `/newpack` route to a different URL
|
||||
- [ ] Every handler is bounded by an explicit deadline; no handler can exceed `handlerTimeout`
|
||||
- [ ] A panic in any module handler is contained and logged, and does not terminate the process
|
||||
- [ ] An interrupted `/newpack` can be completed by re-running the same command
|
||||
- [ ] Anonymous group admins and bot senders are refused before any store or API access
|
||||
- [ ] No log line or error string contains the file-download URL
|
||||
- [ ] Module is enabled only by an explicit `MODULES` entry, verified in a deployed environment
|
||||
- [ ] `go test ./...`, `go vet ./...`, `gofmt -l .`, and `golangci-lint run` all clean
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. Both prior questions were resolved by the one-pack-per-user revision: `/editsticker`
|
||||
takes no pack argument because no command but `/newpack` does (former O1), and the
|
||||
per-user pack limit is one (former O2).
|
||||
|
||||
## Design Revisions
|
||||
|
||||
### 2026-08-25 — /delpack confirmations must prove authority, not disprove it
|
||||
|
||||
Removing adoption closed the takeover class, but the same DeleteStickerSet
|
||||
primitive stayed reachable through a stale confirmation. The under-lock re-check
|
||||
was written as a blocklist — it refused only a *pending* record still naming the
|
||||
set — and fell through on the two states that mattered: no record at all, and a
|
||||
record that had moved on. Reproduced with ordinary commands and no attacker:
|
||||
prompt, pack disappears at Telegram, self-heal frees the name, another user
|
||||
claims it, first user presses, their pack is destroyed.
|
||||
|
||||
Inverted to an allowlist: delete only when a confirmed record still names this
|
||||
exact set. A guard phrased as "which states do I refuse" cannot fail closed
|
||||
against a state nobody enumerated. Dropping a pack record also clears any
|
||||
outstanding confirmation, so a dead prompt is gone rather than merely refused.
|
||||
|
||||
This also closes the reservation leak on the confirmed-delete path, since the
|
||||
moved-on case no longer reaches Telegram at all.
|
||||
|
||||
Fixed alongside: resuming an interrupted `/newpack` silently discarded a retyped
|
||||
title and reported success with the old one; a test named for freeing a dead
|
||||
name never asserted it; and `lockUser`'s comment justified the lock with cron
|
||||
and stats-hook contention that does not exist — the map is state-local and this
|
||||
module registers neither.
|
||||
|
||||
### 2026-08-25 — pack adoption removed
|
||||
|
||||
Verification found the round-4 guard defeated by the module's own recovery
|
||||
branch: an inconclusive `GetStickerSet` keeps the reservation, which turns a
|
||||
*fresh* claim into a *resumed* one, so two ordinary `/newpack` commands took
|
||||
over a stranger's pack. `resolveStaleIntent` had a second adopt path that never
|
||||
consulted the guard at all. Reproduced against the real handlers.
|
||||
|
||||
That is the fourth consecutive failure of the same mechanism, and the reason is
|
||||
structural rather than a bug that can be patched. Adoption needs to prove "this
|
||||
set is mine to finish" from local state, and local state is exactly what a
|
||||
restart on the in-memory backend erases while the packs at Telegram survive.
|
||||
Once the proof is gone the honest and the malicious case are indistinguishable.
|
||||
|
||||
Adoption is therefore removed entirely — both branches. `/newpack` refuses any
|
||||
name a set already occupies, for everyone, whatever the records say.
|
||||
|
||||
Implementing it surfaced a second hole the option did not cover: a *pending*
|
||||
record is not evidence either, and `/delpack` deletes by set name, which
|
||||
Telegram authorises for every set this bot created. Keeping a refused intent so
|
||||
the user could `/delpack` it would hand over a way to destroy the pack we had
|
||||
just refused to adopt. `/delpack` now clears a pending record locally and calls
|
||||
Telegram only for a confirmed one.
|
||||
|
||||
Accepted cost: a crash between creating a set and recording it strands that set
|
||||
permanently. Documented rather than mitigated, because every mitigation is the
|
||||
mechanism that just failed four times.
|
||||
|
||||
### 2026-08-25 — three-lens review pass (security, correctness, tests)
|
||||
|
||||
Three independent reviewers ran against the finished module. What they changed:
|
||||
|
||||
- **Adoption no longer trusts the reservation alone.** The reservation proves
|
||||
ownership only while it outlives the sets it guards, and it does not: it lives
|
||||
in our store, the packs live at Telegram, and the in-memory backend is
|
||||
selected silently whenever `MONGO_URL` is unset. After any wipe the original
|
||||
takeover was reachable again. `createOrAdopt` now refuses to adopt when *this*
|
||||
invocation first claimed the name — a genuine interrupted attempt always finds
|
||||
its own reservation waiting, so there are no false negatives.
|
||||
- **Post-action cleanup reads moved onto the detached context.** They wrote via
|
||||
`commitContext` but read on the request context, so a cancelled request failed
|
||||
the read and skipped the release while still deleting the pack record —
|
||||
stranding a name permanently. Found independently by two reviewers.
|
||||
- **Emoji clustering.** Nine valid emoji were refused outright; tag-sequence
|
||||
flags shattered; and three inputs (trailing ZWJ, a joiner before a flag, an
|
||||
odd regional-indicator count) passed validation and would have sent an
|
||||
`emoji_list` Telegram rejects.
|
||||
- **A reply tail is now reserved** from the handler budget via the existing
|
||||
`chathelper.FetchContext`, which every other data-fetching module already
|
||||
used. A slow photo `/newpack` could spend the whole 10s before Telegram was
|
||||
called, and then send its error reply on a dead context — the user saw nothing.
|
||||
- **The compression ladder resamples the scaled image, not the source.** It was
|
||||
paying a full-size resample per rung: measured 1.99s versus 74ms for the three
|
||||
rungs, on a dispatcher that runs handlers one at a time.
|
||||
- **Tests.** Mutation testing refuted the previous round's non-vacuity claim in
|
||||
three places. The `created`-flag release machinery had no coverage in either
|
||||
direction; two emoji assertions passed against a handler that never called
|
||||
Telegram; and nothing pinned module registration — the whole module could be
|
||||
removed from `factories()` with the suite still green.
|
||||
|
||||
Left open deliberately: the shutdown path never joins the polling goroutine, so
|
||||
`context.WithoutCancel` does not actually survive process exit. That is
|
||||
`cmd/server/main.go`, outside this module and affecting every module's commits.
|
||||
|
||||
### 2026-08-25 — one pack per user
|
||||
|
||||
The accepted scope originally chose "named packs, multiple per user, addressed by slug
|
||||
argument". The user revised it to one pack per user, with every command operating on that
|
||||
pack implicitly.
|
||||
|
||||
Removed by the revision:
|
||||
|
||||
- `<pack>` arguments on `/addsticker`, `/renamepack`, and `/delpack`
|
||||
- `/packlist` (replaced by `/mypack`, singular) and its `List` + per-pack `Get` fan-out
|
||||
- `maxPacksPerUser` as a tunable — the limit is one
|
||||
- The separate default-pack command and per-user prefs record that a multi-pack default
|
||||
would have required
|
||||
|
||||
Red-team findings this revision resolves outright rather than mitigates:
|
||||
|
||||
- Finding 4 (`/packlist` unbounded: 60s client timeout x 10 calls plus an N+1) — `/mypack`
|
||||
is a single `Get` and makes no API calls at all
|
||||
- Former open question O2 (`maxPacksPerUser` sizing) — no longer a choice
|
||||
|
||||
Unaffected: Phase 1 (panic barrier, test harness) and Phase 5 (photo pipeline, token-leak
|
||||
sentinel) need no changes. The write-ahead intent machinery, anonymous-sender rule,
|
||||
`handlerTimeout`, and bot-rename resilience all carry over unchanged.
|
||||
|
||||
Trade-off accepted: a user who wants a memes pack and a reactions pack separately cannot
|
||||
have both (R10).
|
||||
|
||||
### 2026-08-25 — `/delpack` as the URL-change path
|
||||
|
||||
`/delpack` was reviewed as a destructive convenience. It is in fact the **only** mechanism for
|
||||
changing a pack's URL, because Telegram exposes no rename-short-name method. That reframing
|
||||
changed three things without adding a command:
|
||||
|
||||
- `/renamepack`'s reply now names the delete-and-recreate route instead of only stating that
|
||||
the link cannot change — turning a dead end into an answer.
|
||||
- `/delpack`'s confirm prompt must state the pack title, the sticker count being destroyed, the
|
||||
exact link being surrendered, and that both are permanent. It is the last point at which a
|
||||
user learns stickers do not survive a URL change.
|
||||
- A `/repack <newslug>` migration command was considered and **rejected for this plan**: copying
|
||||
a full pack is up to ~121 sequential API calls, which blows `handlerTimeout` and stalls the
|
||||
bot for every user under C1. It is viable only after Phase 5's goroutine offload lands, and is
|
||||
recorded here as a follow-up rather than scoped in.
|
||||
|
||||
The @Stickers command set was mapped exhaustively against the one-pack model; all nine of our
|
||||
commands are either direct adaptations or (for `/mypack`) a justified addition, and every
|
||||
dropped @Stickers command has a stated reason.
|
||||
|
||||
**Bug found during this pass** (Phase 3, `/newpack`): the different-slug pending branch
|
||||
overwrote the pending record unconditionally, which permanently orphans a set that was created
|
||||
before an interruption. It now probes `GetStickerSet(oldName)` first and adopts rather than
|
||||
overwrites when the old set exists.
|
||||
|
||||
|
||||
### 2026-08-25 — post-implementation review: global slug reservation
|
||||
|
||||
An independent review of the implemented module found a **pack takeover** in
|
||||
`/newpack`, and it traces back to this plan, not only to the code.
|
||||
|
||||
Phase 3 step 5 said: `GetStickerSet` succeeds → "we hold a `Pending` record for
|
||||
this owner and slug, so this is our own interrupted attempt: adopt it". The
|
||||
plan's own risk section stated the supporting invariant as *"a `Pending` record
|
||||
for an owner means that owner asked for that name"*. That is true and **not
|
||||
sufficient**. Asking is not creating. `PutVersioned` is create-only per *owner
|
||||
key* (`packKey` is the owner ID alone); nothing reserved a name globally. So a
|
||||
user with no pack who typed another user's slug produced byte-identical evidence
|
||||
to a genuine resumed attempt — and adopted their pack, then could `/delpack` it.
|
||||
Every share link is public, so slugs are trivially enumerable.
|
||||
|
||||
The plan pre-authorised one response to this signal ("disable adoption — a
|
||||
one-line change"), which would have closed the hole by dropping the
|
||||
"an interrupted `/newpack` can be completed by re-running" success criterion.
|
||||
The user chose the stronger fix instead:
|
||||
|
||||
**A global slug reservation.** `SlugReservation{Slug, OwnerID}` is written
|
||||
create-only under `slug:<slug>` *before* Telegram is touched. Adoption is
|
||||
allowed only when the reservation names the caller. The first claimant of a name
|
||||
is the only user who can ever adopt a set under it, so "this set is mine" became
|
||||
a proven fact rather than an assumption — and both success criteria survive.
|
||||
|
||||
Consequent changes:
|
||||
|
||||
- `reserveSlug` runs before `claimSlug`; a name held by anyone else replies
|
||||
"that pack name is taken" with **zero** API calls.
|
||||
- `resolveStaleIntent` re-proves the *old* slug's reservation before adopting
|
||||
under it, and releases it on a positive "no such set".
|
||||
- `dropIntent` no longer fires on unknown errors (was a plan rule 4 violation in
|
||||
its own right): on anything but a *classified* refusal both the intent and the
|
||||
reservation survive, which is what lets a re-run recover. Only a positive
|
||||
refusal releases them.
|
||||
- `/delpack` keeps the reservation, so a deleted name stays recoverable by its
|
||||
owner and unavailable to everyone else — which also matches Telegram's likely
|
||||
behaviour for deleted short names (R11).
|
||||
|
||||
Four further defects fixed in the same pass:
|
||||
|
||||
- **A stale `/delpack` confirmation deleted a live pack's record.** Pending
|
||||
actions used a random key per invocation, so two could be live at once, and
|
||||
the delete path cleared the record *by owner* without checking it still named
|
||||
the set being deleted — precisely the documented delete-then-recreate URL
|
||||
change. Now keyed per user (matching `stock/pending_dividend.go`), superseded
|
||||
by a newer prompt, and gated on `ownsSet` before clearing.
|
||||
- **Unbounded storage from a public command.** The same random key meant a user
|
||||
who ran `/delpack` and never tapped left a permanent document.
|
||||
- **The panic barrier missed the detached hook goroutine**, so a panicking
|
||||
`CommandHook` (the `stats` module ships one) still killed the process. C9's
|
||||
"Phase 1 closes this" was true for handlers only.
|
||||
- `/delpack`'s result message bypassed `chathelper.Reply`, so in a forum
|
||||
supergroup it landed in General instead of the topic.
|
||||
|
||||
### 2026-08-25 — Phase 4 review pass
|
||||
|
||||
Ten findings applied before implementation began. Six changed what gets written:
|
||||
|
||||
- `resolveSource` takes `(ctx, b, ownerID, msg)` from the start, and `photoRef` is gone — the
|
||||
photo branch resolves to a `file_id` like every other path, so Phase 5 adds a branch instead
|
||||
of rewriting call sites.
|
||||
- The static-only gate moves onto `resolveSource`, the path that can actually receive a
|
||||
non-static sticker, and now also rejects `Type != "regular"` — a mask sticker is static yet
|
||||
invalid for a regular set, which `IsAnimated || IsVideo` does not catch.
|
||||
- `/addsticker` gained its missing no-pack path, and the note on why its refusal is
|
||||
deliberately *not* the uniform `resolveOwned` one.
|
||||
- `/delsticker` takes the same per-user keylock as `/addsticker`; both do the same
|
||||
read-modify-write on `Count`.
|
||||
- `/delsticker`'s "never deletes the record" criterion was scoped to transient and unknown
|
||||
errors. Unqualified, it forbade Phase 3's `STICKERSET_INVALID` self-heal — the very
|
||||
mechanism that unwedges a user whose set Telegram removed.
|
||||
- The animated/video check moved ahead of the store read in `resolveOwned`'s numbered gate.
|
||||
|
||||
Also: the `Count: 0` aftermath now names its recovery route, `STICKERS_TOO_MUCH` and the
|
||||
no-pack paths gained success criteria, R12 records the unverified `file_id`-reuse premise, and
|
||||
Phase 6 must document `<name...>` in the conventions doc rather than shipping an undocumented
|
||||
parameter form.
|
||||
|
||||
#### Consistency sweep — one-pack revision
|
||||
|
||||
- Files reread: plan.md and all six phase files.
|
||||
- Deltas checked: 9 (`<pack>` dropped from `/addsticker`, `/renamepack`, `/delpack`;
|
||||
`/packlist` -> `/mypack`; `listPacks` -> `getPack`; key `<ownerID>:<slug>` -> `<ownerID>`;
|
||||
`maxPacksPerUser` removed; `matchPack` -> `ownsSet`; `buildSetName` -> `makeSetName`).
|
||||
- Stale references reconciled: 4 (incl. the frontmatter description, which said
|
||||
"multi-pack-per-user" and is surfaced by every `ak plan list`). Two live risk signals in the R2/R7 rows still named
|
||||
`/packlist`; Phase 5's `/setpackicon` still said "one of the caller's packs". Phase 5 was
|
||||
therefore **not** untouched, contrary to the initial assessment.
|
||||
- Remaining `/packlist`, `listPacks`, and `maxPacksPerUser` mentions are all deliberate
|
||||
comparative or historical text ("this replaced X", the revision log, the red-team table).
|
||||
- Phases 1 and 5 confirmed free of multi-pack phrasing after the fix.
|
||||
- Unresolved contradictions: 0
|
||||
|
||||
## Red Team Review
|
||||
|
||||
### Session — 2026-08-25
|
||||
**Findings:** 28 raw from 3 reviewers → 16 after dedup (16 accepted, 0 rejected)
|
||||
**Severity breakdown:** 7 Critical, 6 High, 3 Medium
|
||||
**Reviewers:** Security Adversary (Fact Checker), Failure Mode Analyst (Flow Tracer),
|
||||
Assumption Destroyer (Scope Auditor). All findings carried `file:line` evidence, so none
|
||||
were filtered. Four of the seven Critical findings were independently corroborated by all
|
||||
three reviewers.
|
||||
|
||||
| # | Finding | Severity | Disposition | Applied To |
|
||||
|---|---------|----------|-------------|------------|
|
||||
| 1 | Empty `MODULES` loads all modules; "opt-in" false in 3 places | Critical | Accept | C10, Phase 6, Goals |
|
||||
| 2 | No `recover()` on the update path; a handler panic kills the process | Critical | Accept | C9, Phase 1 |
|
||||
| 3 | Bot token leaks via `*url.Error` into the dispatcher log | Critical | Accept | Rule 5, R5, Phase 5 |
|
||||
| 4 | `/packlist` unbounded: 60s client timeout × 10 calls + N+1 | Critical | Accept | C2, Rule 1, Phase 3 |
|
||||
| 5 | `/delsticker` probe deletes the record on any error | Critical | Accept | Rule 4, R7, Phase 4 |
|
||||
| 6 | `/delpack` button is a permanent replayable delete capability | Critical | Accept | Phase 3 |
|
||||
| 7 | `RecordingBot` cannot return structs; 3 phases untestable | Critical | Accept | C11, Phase 1 |
|
||||
| 8 | Anonymous group admins share one `From.ID` | High | Accept | Rule 3, R6 |
|
||||
| 9 | Bot rename orphans every pack; `Pack.Name` written never read | High | Accept | R8, Phase 2, Phase 4 |
|
||||
| 10 | `GetStickerSet` not-found is an undefined branch | High | Accept | Rule 4, Phase 3 |
|
||||
| 11 | Commit writes on `rootCtx`; deploy is the normal orphan path | High | Accept | Rule 2, R2, Phase 3 |
|
||||
| 12 | `/help` 4096-rune ceiling; 884 runes headroom measured | High | Accept | Phase 6 |
|
||||
| 13 | C5's no-adoption conclusion not forced by its premise | High | Accept | C5, R2, Phase 3 |
|
||||
| 14 | `Put` used where create-only `PutVersioned` exists | Medium | Accept | Phase 3 |
|
||||
| 15 | `parseSlug` case-preserving value reaches the storage key | Medium | Accept | Folded into #9 |
|
||||
| 16 | `/newpack` oracle contradicts the plan's own no-disclosure claim | Medium | Accept | Accepted disclosure section |
|
||||
|
||||
**User decisions taken during adjudication:** explicit `MODULES` before Phase 6; panic
|
||||
barrier in `modules.Install` as Phase 1; module-wide deadline plus persisted counts;
|
||||
write-ahead intent record for orphan recovery.
|
||||
|
||||
### Whole-Plan Consistency Sweep
|
||||
- Files reread: plan.md, phase-01-shared-prerequisites.md, phase-02-store-setname-emoji.md,
|
||||
phase-03-pack-lifecycle.md, phase-04-sticker-commands.md, phase-05-photo-pipeline.md,
|
||||
phase-06-wiring-docs.md
|
||||
- Decision deltas checked: 14 (phase renumber 1-5 -> 1-6; `parseSlug` -> `matchPack`;
|
||||
`/delsticker` probe removed; `/packlist` API-free via `Pack.Count`; `Pack.Pending`
|
||||
write-ahead intent; callback payload slug -> opaque id; C5 rewritten; opt-in claims
|
||||
removed; `handlerTimeout` cross-cutting rule; `senderID` rule; `isStickerSetMissing`;
|
||||
download sentinel; slug cap decoupled from C3; `Put` -> `PutVersioned` for create)
|
||||
- Reconciled stale references: 0 remaining. `parseSlug` survives only in phase-02 as the
|
||||
named superseded design and in the red-team table as finding #15 — both deliberate
|
||||
historical references, not live claims. "opt-in" survives only in C10's negative heading
|
||||
and the finding that corrected it.
|
||||
- Link integrity: all 6 phase links resolve; no orphan phase files.
|
||||
- Cross-phase references verified consistent under the new numbering.
|
||||
- Unresolved contradictions: 0
|
||||
|
||||
<!-- slug: sticker-pack-module -->
|
||||
@@ -0,0 +1,423 @@
|
||||
# Correctness / Crash-safety / Concurrency Review — internal/modules/sticker
|
||||
|
||||
Date: 2026-08-25 · Reviewer lens: correctness, crash-safety, concurrency.
|
||||
Out of scope by assignment: cross-user security impact, test quality.
|
||||
Scope: uncommitted `internal/modules/sticker/` (~1.9k LOC non-test) plus the
|
||||
modified `cmd/server/main.go`, `internal/modules/dispatcher.go`, and the
|
||||
storage/keylock contracts they rely on.
|
||||
|
||||
`go build ./...` clean. `go vet` clean on sticker/storage/modules.
|
||||
`go test ./internal/modules/sticker/` passes.
|
||||
|
||||
## Verdict
|
||||
|
||||
The write-ahead intent state machine is sound. Every /newpack interruption point
|
||||
recovers or refuses; none wedges the user permanently on paths reachable in this
|
||||
deployment. Two real findings (H-1, H-2) undermine the *durability* half of the
|
||||
design rather than its logic. The rest is MEDIUM/LOW.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### H-1 (HIGH) — `commitContext`'s SIGTERM protection is defeated by the shutdown path
|
||||
|
||||
`cmd/server/main.go:214-228`, `internal/modules/sticker/state.go:53-59`
|
||||
|
||||
`commitContext` uses `context.WithoutCancel(ctx)` + 5s so a post-action commit
|
||||
survives SIGTERM. That only defends against *context* cancellation. The process
|
||||
does not wait for it:
|
||||
|
||||
```
|
||||
go func() { b.Start(rootCtx) }() // main.go:214 — return value never awaited
|
||||
<-rootCtx.Done() // main.go:220
|
||||
srv.Shutdown(shutdownCtx) // HTTP only
|
||||
} // main returns -> defer closeProvider() -> process exits
|
||||
```
|
||||
|
||||
`b.Start` *would* drain correctly — with `WithNotAsyncHandlers` + 1 worker
|
||||
(`telegram/client.go:28`, lib `defaultWorkers = 1`) its `wg.Wait()` blocks until
|
||||
the inline handler returns — but `main` never joins that goroutine. On SIGTERM
|
||||
`main` proceeds as soon as `srv.Shutdown` finishes (immediate with no in-flight
|
||||
HTTP), runs `defer closeProvider()` (main.go:125), and exits. The detached
|
||||
commit gets milliseconds, then the Mongo client is disconnected under it.
|
||||
|
||||
Failure scenario: deploy lands while user runs `/newpack foo Bar`.
|
||||
`CreateNewStickerSet` returns 200; `finishNewPack` → `commitPack` → detached
|
||||
`Put` starts; SIGTERM arrives; process exits before the write lands. Record stays
|
||||
`Pending:true`. Recoverable (re-run `/newpack foo …` adopts), so not data loss —
|
||||
but the write-ahead recovery path is exercised routinely rather than rarely, and
|
||||
the comment's claim ("must not be lost because the process is shutting down") is
|
||||
false as built.
|
||||
|
||||
Same exit kills `adjustCount`'s commit (count silently under-counts, never
|
||||
self-corrects) and `renamePack`'s commit (stored title diverges from Telegram
|
||||
permanently).
|
||||
|
||||
Fix direction: have `main` wait for the polling goroutine before returning —
|
||||
e.g. `pollDone := make(chan struct{}); go func(){ b.Start(rootCtx); close(pollDone) }()`
|
||||
then `select { case <-pollDone: case <-time.After(shutdownGrace): }` before
|
||||
`srv.Shutdown` / `closeProvider`. Grace must exceed `commitTimeout` (5s).
|
||||
|
||||
### H-2 (HIGH) — every reply is sent on the same exhausted context the API work drained
|
||||
|
||||
`internal/modules/sticker/state.go:62-65` and all nine handlers.
|
||||
|
||||
`handlerContext` = 10s for the whole handler. Nothing reserves a tail for the
|
||||
reply. `chathelper.Reply` sends with that same ctx, so once the budget is spent
|
||||
the user gets **no message at all** — success or failure.
|
||||
|
||||
Concrete: `/newpack mypack My Pack` replying to a **photo**.
|
||||
`resolveSource` → `downloadFile` (own 8s client timeout, but also bounded by the
|
||||
handler ctx) → `toStickerPNG` (CatmullRom on up to 4096×4096) → `UploadStickerFile`.
|
||||
On a slow link that is 7-9s. `CreateNewStickerSet` then runs on <1-3s and
|
||||
returns `context.DeadlineExceeded` — which is correctly *not* `createRefused`,
|
||||
so intent + reservation are kept — and `replyAPIError` then calls
|
||||
`reply(ctx, …)` on the dead context. User sees silence, has a `Pending` record
|
||||
and a burned reservation, and no indication what happened. `/mypack` does show
|
||||
the pending marker, so it is discoverable, not wedged.
|
||||
|
||||
The repo already has the fix pattern and this module is the only one not using
|
||||
it: `chathelper.FetchContext` reserves a 3s reply tail and is used by
|
||||
`coin/views.go:35`, `gold/handlers.go:29,185`, `stock/stock_events.go:78`,
|
||||
`monkeyd/tags_command.go:81`.
|
||||
|
||||
Fix direction: derive `fetchCtx, cancel := chathelper.FetchContext(ctx)` for the
|
||||
download/upload/Telegram calls and keep the outer `ctx` for `reply`.
|
||||
|
||||
### M-1 (MEDIUM) — post-action cleanup helpers *read* on the cancellable context
|
||||
|
||||
`pack_handlers.go:179` (`releaseSlug`), `:397` (`adjustCount`), `:432`
|
||||
(`dropPackRecordIfSet`), `:464` (`dropPackRecord`).
|
||||
|
||||
Each of these runs *after* a confirmed Telegram-side action, and each carefully
|
||||
wraps its **write** in `commitContext` — but performs the **read** it depends on
|
||||
with the caller's cancellable `ctx`. A cancelled/expired ctx therefore silently
|
||||
skips the write.
|
||||
|
||||
- `adjustCount:397` — ctx expired right after a successful `AddStickerToSet` →
|
||||
`getPack` fails → count increment never persisted. The delta is lost forever
|
||||
(the next adjust reads the stale base). Handler falls back to an in-memory
|
||||
`pack.Count++` purely for the reply, so the user is told a number that was
|
||||
never stored.
|
||||
- `releaseSlug:179` — ctx expired in the `createRefused` branch
|
||||
(`pack_handlers.go:351-354`) → reservation read fails → name stays reserved
|
||||
with no pack behind it. The owner can re-reserve (owner check passes), so the
|
||||
loss is only to other users' namespace, but it is permanent.
|
||||
- `dropPackRecord:464` — read fails → record is still deleted (correct: keeping
|
||||
it would block `/newpack`) but the slug can never be freed, because the record
|
||||
was the only thing that knew the name.
|
||||
|
||||
Fix direction: derive the commit context once at the top of each helper and use
|
||||
it for both the read and the write.
|
||||
|
||||
### M-2 (MEDIUM) — emoji: nine valid RGI emoji are refused outright
|
||||
|
||||
`emoji.go:130-156` (`isEmojiRune`). Verified by running `parseEmoji` against
|
||||
each codepoint:
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `©️` U+00A9, `®️` U+00AE | refused |
|
||||
| `〰️` U+3030, `〽️` U+303D | refused |
|
||||
| `㊗️` U+3297, `㊙️` U+3299 | refused |
|
||||
| `Ⓜ️` U+24C2 | refused |
|
||||
| `⤴️` U+2934, `⤵️` U+2935 | refused |
|
||||
|
||||
All are in Telegram's emoji keyboard. `™️` U+2122 and `ℹ️` U+2139 are special-cased
|
||||
but their neighbours are not. `/editsticker ©️` fails with "is not an emoji".
|
||||
|
||||
Fix direction: add U+00A9, U+00AE, U+2934-2935, U+3030, U+303D, U+3297, U+3299,
|
||||
U+24C2 to the singleton/range list (2900-297F would also cover the arrows).
|
||||
|
||||
### M-3 (MEDIUM) — emoji: tag-sequence flags shatter, and the refusal prints raw tag characters
|
||||
|
||||
`emoji.go:69-102`. `isBinding` covers Mn/Me but tag characters (U+E0020-E007F)
|
||||
are category **Cf**, so `🏴` (England/Scotland/Wales flags) splits into
|
||||
`["🏴", "\U000e0067", "\U000e0062", …]`. Verified output:
|
||||
|
||||
```
|
||||
in="🏴\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f"
|
||||
clusters=["🏴" "\U000e0067" … ] err="\U000e0067" is not an emoji.
|
||||
```
|
||||
|
||||
Two problems: a legitimate emoji is rejected, and `%q` renders an invisible tag
|
||||
char, so the user is told `"\U000e0067" is not an emoji`.
|
||||
|
||||
Fix direction: treat U+E0020-U+E007F as binding, terminating the cluster at
|
||||
U+E007F (cancel tag).
|
||||
|
||||
### M-4 (MEDIUM) — emoji: three inputs pass validation and send an invalid `emoji_list` to Telegram
|
||||
|
||||
`emoji.go:69-102`, `:117-128`. Verified:
|
||||
|
||||
| Input | Clusters produced | Sent to Telegram |
|
||||
|---|---|---|
|
||||
| `😀` (trailing ZWJ) | `["😀"]` | yes → `STICKER_EMOJI_INVALID` |
|
||||
| `😀🇻🇳` | `["😀🇻", "🇳"]` — the ZWJ branch (`emoji.go:83-88`) swallows the first regional indicator, orphaning the second | yes |
|
||||
| `🇻🇳🇺` (odd RI count) | `["🇻🇳", "🇺"]` — lone RI passes `isEmojiRune` via `emoji.go:154` | yes |
|
||||
|
||||
Not a crash: `apiRefusal` maps `STICKER_EMOJI_INVALID` to "Telegram rejected
|
||||
those emoji", so the user gets a sane message after one wasted API round-trip.
|
||||
Correctness bug, low impact.
|
||||
|
||||
Fix direction: reject a cluster ending in ZWJ; do not classify a lone regional
|
||||
indicator as emoji; do not let the ZWJ branch consume a regional indicator.
|
||||
|
||||
### L-1 (LOW) — `/newpack` does all its expensive work before checking whether the caller already has a pack
|
||||
|
||||
`pack_handlers.go:68-99`. `resolveSource` (photo path: GetFile + up to 2 MB
|
||||
download + resize + `UploadStickerFile`) and `resolver.resolve` (GetMe) run
|
||||
*before* the lock and before the "you already have a pack" pre-check. A user who
|
||||
already owns a pack pays a full download+upload and creates a file on Telegram's
|
||||
servers, then is refused. Wasted work only; no state divergence. Moving the
|
||||
pre-check above `resolveSource` would also buy back budget for H-2 — but note
|
||||
the pre-check must stay *after* `lockUser` and *before* `reserveSlug`, which is
|
||||
the ordering the comment at `:85-91` is defending.
|
||||
|
||||
### L-2 (LOW) — `dropPackRecord` is called both inside and outside `lockUser`
|
||||
|
||||
Inside: `handleAddSticker` (`sticker_handlers.go:80`), `handleDelSticker` (`:129`),
|
||||
`handleRenamePack` (`pack_handlers.go:544`).
|
||||
Outside: `handleEditSticker` (`sticker_handlers.go:172`), `handleOrderSticker`
|
||||
(`:210`), `handleSetPackIcon` (`setpackicon.go:45`).
|
||||
|
||||
`dropPackRecord` is a read → delete → release-slug sequence. Unlocked call sites
|
||||
could delete a record another handler just committed.
|
||||
|
||||
**Not reachable in production today**: dispatch is inline with one worker
|
||||
(`bot.WithNotAsyncHandlers()`, `defaultWorkers = 1`), the sticker module
|
||||
registers no cron job, and the dispatcher's detached per-command stats hook
|
||||
(`dispatcher.go:80-88`) writes only to the `stats` collection. Latent only —
|
||||
flagging because the inconsistency reads as an oversight rather than a decision.
|
||||
|
||||
Same class, same reachability: `handleAddSticker` resolves `pack` at
|
||||
`sticker_handlers.go:41` *before* `defer s.lockUser(...)()` at `:66`, then uses
|
||||
`pack.Name` for the API call. `adjustCount` correctly re-reads under the lock,
|
||||
but the API call itself uses the pre-lock value. `handleRenamePack` likewise
|
||||
reads at `pack_handlers.go:531` and `Put`s that whole stale record at `:550`,
|
||||
which would clobber a concurrent `Count` change.
|
||||
|
||||
### L-3 (LOW) — `/ordersticker` reports a position Telegram may not have honoured
|
||||
|
||||
`sticker_handlers.go:196-222`. Upper bound is deliberately delegated to Telegram
|
||||
(correct — a local `Count` is advisory). But if `SetStickerPositionInSet` clamps
|
||||
an out-of-range position instead of erroring, the reply "Moved to position N"
|
||||
states something false. Consider "Moved." or re-reading the set.
|
||||
|
||||
### L-4 (LOW) — `photoFileID` picks by `FileSize`, which may be absent
|
||||
|
||||
`photo.go:57-63`. `PhotoSize.FileSize` is optional in the Bot API. If Telegram
|
||||
omits it for every size, all compare equal to 0 and `Photo[0]` — the *smallest*
|
||||
thumbnail — is chosen, yielding a blurry sticker. Telegram populates it in
|
||||
practice. Tie-break on `Width*Height` instead.
|
||||
|
||||
### L-5 (LOW) — state divergence on a DB reset / collection drop
|
||||
|
||||
`pack_handlers.go:313-320`. `createOrAdopt`'s `err == nil` branch adopts any
|
||||
existing set under `<slug>_by_<bot>`, and its safety rests entirely on the
|
||||
reservation table being authoritative. If the module's collection is dropped or
|
||||
the same bot token is pointed at a fresh Mongo, the reservations vanish while the
|
||||
Telegram sets do not: the next claimant of a previously-used slug reserves it
|
||||
cleanly (`created == true`), then adopts the *previous* owner's set, and the
|
||||
record's `Count = 1` (`:363-365`) will disagree with the real set.
|
||||
|
||||
The cross-user impact belongs to the security reviewer; noting it here only as a
|
||||
Count/ownership divergence and an operational constraint. Fix direction is
|
||||
operational, not code: never drop this collection while packs exist, or gate
|
||||
adoption on `created == false`.
|
||||
|
||||
### Nit — `apiRefusal` hardcodes `120` instead of `maxStickersPerPack`
|
||||
|
||||
`errors.go:76` says "Your pack is full (120 stickers)." while
|
||||
`sticker_handlers.go:17` defines the const. The const is otherwise referenced
|
||||
only from tests.
|
||||
|
||||
---
|
||||
|
||||
## /newpack interruption-point table
|
||||
|
||||
Notation: **R** = slug reservation (`slug:<slug>`), **P** = Pack record.
|
||||
"Next `/newpack <same slug>`" and "Next `/newpack <other slug>`" are the two
|
||||
recovery entry points. All rows traced against `handleNewPack`
|
||||
(`pack_handlers.go:45-118`) and its four helpers.
|
||||
|
||||
| # | Interruption point | State left behind | Next `/newpack` **same** slug | Next `/newpack` **different** slug | Wedged? |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Before `reserveSlug` (arg/slug/title/source/GetMe failure, `:50-81`) | none | normal create | normal create | no |
|
||||
| 2 | Between pre-check (`:92`) and `reserveSlug` write | none | normal create | normal create | no |
|
||||
| 3 | After R written, before `claimSlug` (`:101-106`) | R only | `reserveSlug` conflict → own → resume (`created=false`), `claimSlug` creates P, create proceeds | R(old) orphaned; new R created; P created for new slug. Old R leaks (owner-held, re-reservable by owner) | no |
|
||||
| 4 | `claimSlug` fails/answers with `created=true` (`:106-115`) | R released at `:112` | normal create | normal create | no |
|
||||
| 5 | After P(pending) written, before `GetStickerSet` (`:106→313`) | R + P(pending) | `claimSlug` → `existing.Slug == slug` → resume → `GetStickerSet` missing → create | `resolveStaleIntent` (`:253`): R(old) held by caller, `GetStickerSet(old)` **missing** → `Put(new intent)`, `releaseSlug(old)` → create | no |
|
||||
| 6 | `GetStickerSet` returns unknown error (`:325-330`) | R + P(pending) unchanged — **deliberately untouched** | retry; succeeds once Telegram answers | as row 5 | no |
|
||||
| 7 | Between `GetStickerSet`(missing) and `CreateNewStickerSet` (`:337`) | R + P(pending) | resume → create | as row 5 | no |
|
||||
| 8 | `CreateNewStickerSet` returns a `createRefused` code (`:351-354`) | R released, P dropped | clean retry (same refusal until the cause changes) | clean create | no |
|
||||
| 9 | `CreateNewStickerSet` returns a non-refusal error (timeout/429/SIGTERM) — **set may or may not exist** (`:355`) | R + P(pending) kept | `GetStickerSet` decides: exists → adopt + commit; missing → create. Both correct | `resolveStaleIntent` probes old name: exists → **adopt old**, tell user "restored"; missing → release old, create new | no |
|
||||
| 10 | Create succeeded server-side, process dies before `finishNewPack` | R + P(pending); set exists | `GetStickerSet` → exists → `finishNewPack(adopted=true)`, `Count=1` | `resolveStaleIntent` → old set exists → adopt, refuse the new name with "restored" | no |
|
||||
| 11 | `commitPack` inside `finishNewPack` fails (`:366-369`) | R + P(pending); set exists | as row 10 → adopt + commit | as row 10 | no |
|
||||
| 12 | Process exits during the detached `commitPack` (**H-1**) | identical to row 11 | as row 10 | as row 10 | no |
|
||||
| 13 | `resolveStaleIntent` dies between `Put(new intent)` (`:296`) and `releaseSlug(old)` (`:300`) | R(old) orphaned + R(new) + P(new, pending) | resume new slug → create | probes new slug's set | no; old R leaks permanently to other users |
|
||||
| 14 | P(pending) exists but its R is now held by someone else | P(pending) + foreign R | `claimSlug` → `existing.Slug == slug` → resume → `GetStickerSet` → **if the other holder created it, this adopts their set** | `resolveStaleIntent` → `held.OwnerID != caller` → drop dead intent, proceed cleanly | see L-5 |
|
||||
|
||||
Row 14 is only reachable via the L-5 reservation-loss scenario; in normal
|
||||
operation `reserveSlug` runs before `claimSlug`, so a pending P always implies
|
||||
the caller held R at the moment P was written, and no code path transfers R
|
||||
between users (`releaseSlug:187` re-verifies ownership).
|
||||
|
||||
**Escape hatch verified**: `handleDelPack` (`delpack_callback.go:22`) does *not*
|
||||
require `!Pending`, so a user stuck on a pending record can always clear it.
|
||||
`DeleteStickerSet` on a never-created set returns `STICKERSET_INVALID`, which
|
||||
`isStickerSetMissing` treats as success (`:180`), dropping P and releasing R.
|
||||
|
||||
---
|
||||
|
||||
## Verified sound
|
||||
|
||||
Checked and found correct; no action needed.
|
||||
|
||||
**State machine / ordering**
|
||||
- Pre-check "already has a finished pack" is inside `lockUser` and before
|
||||
`reserveSlug` (`:83-99`). Reversing those two is the name-burning primitive the
|
||||
comment describes; the order is right.
|
||||
- `claimSlug` uses `PutVersioned(…, 0, …)` (create-only), never `Put`. Both
|
||||
backends give exactly one winner: Mongo via the version-0/absent filter +
|
||||
upsert + `_id` duplicate-key (`mongo_doc_store.go:85-105`); memory via
|
||||
`ErrConflict` when the key exists (`memory_provider.go:107-110`).
|
||||
- `created` is threaded correctly: `reserveSlug` returns `false` when it merely
|
||||
resumes an existing reservation (`:165`), so the bail path at `:111` never
|
||||
releases a reservation predating the invocation.
|
||||
- `releaseSlug` re-verifies `held.OwnerID == ownerID` inside the operation
|
||||
(`:187`), not at the call site.
|
||||
- `dropPackRecord` reads before deleting so the slug is still known (`:464-480`),
|
||||
and deletes the record before releasing the name — the safe ordering (the
|
||||
reverse would free a name while a record still claims it).
|
||||
- `dropPackRecordIfSet` guards on `ownsSet` (`:440`) so a stale `/delpack`
|
||||
confirmation cannot erase a newer pack's record.
|
||||
- `resolveStaleIntent` re-proves reservation ownership rather than inferring it
|
||||
from the pending record (`:258-272`).
|
||||
|
||||
**Error classification**
|
||||
- `isStickerSetMissing` requires both `bot.ErrorBadRequest` **and** the
|
||||
`STICKERSET_INVALID` substring (`errors.go:43-46`). `context.DeadlineExceeded`,
|
||||
`context.Canceled`, 429, and transport errors all fall through — they never
|
||||
authorise a record delete. Verified at all six call sites.
|
||||
- `createRefused` (`errors.go:113-123`) lists only request-validation codes and
|
||||
is kept separate from `apiRefusal` despite the overlap. Every code listed
|
||||
genuinely proves nothing was created.
|
||||
- No path infers absence from a generic failure. `createOrAdopt`'s `default`
|
||||
branch (`:325-330`) and `resolveStaleIntent`'s (`:303-307`) both change nothing.
|
||||
|
||||
**Count**
|
||||
- `adjustCount` re-reads inside `lockUser` (`:397`) rather than trusting the
|
||||
handler's pre-lock copy.
|
||||
- Clamped at 0 (`:407-410`); cannot go negative.
|
||||
- Missing record returns `storage.ErrNotFound` and does **not** recreate the
|
||||
record (`:402-405`).
|
||||
- A failed `AddStickerToSet`/`DeleteStickerFromSet` returns before `adjustCount`,
|
||||
so a failed API call never moves the count.
|
||||
- Pack-full (`STICKERS_TOO_MUCH`) returns via `replyAPIError` with no increment.
|
||||
|
||||
**Context**
|
||||
- `commitContext` used at every post-action commit: `commitPack:418`,
|
||||
`dropIntent:381`, `dropPackRecord:471`, `releaseSlug:191`,
|
||||
`dropPendingDelete:~200`, and the `/delpack` prompt persist.
|
||||
- Not used where cancellation should apply: `reserveSlug`'s and `claimSlug`'s
|
||||
pre-action writes, `resolveStaleIntent`'s intent replacement, and the pending
|
||||
action consume in the callback all use the cancellable ctx. Correct.
|
||||
- Every `context.WithTimeout` has a matching `cancel`, all `defer`red. No leaks;
|
||||
`go vet` agrees.
|
||||
|
||||
**Concurrency**
|
||||
- `keylock.Map` zero value is usable; `defer s.lockUser(id)()` acquires eagerly
|
||||
and defers only the unlock — correct idiom at all six call sites.
|
||||
- `state`'s zero-value `usernameResolver` and `nowFn == nil` are both safe
|
||||
(`state.go:47-51`, `setname.go:97-122`), so `New` not initialising them is fine.
|
||||
- `usernameResolver.resolve` never caches a failure and holds no lock across the
|
||||
`GetMe` call.
|
||||
- Slug races between two *different* users are resolved by store atomicity, not
|
||||
by `keylock` (which is per-user and could not help). Correct choice.
|
||||
- `/delpack` double-press: the pending action is consumed *before*
|
||||
`DeleteStickerSet` (`delpack_callback.go:~183`), so a second press finds
|
||||
`ErrNotFound`. Serialized dispatch makes it moot anyway.
|
||||
|
||||
**Callback safety**
|
||||
- `models.CallbackQuery.Message` is a **value** (`MaybeInaccessibleMessage`), not
|
||||
a pointer, in `go-telegram/bot v1.20.0`. `query.Message.Message` cannot nil-deref;
|
||||
the inner `*Message` nil check is the right and sufficient guard.
|
||||
- Binding check (`chat + message id + non-zero MessageID`) precedes every side
|
||||
effect including `clearButton`.
|
||||
- `parseDeleteCallback` bounds length to 64 and validates hex before use; the id
|
||||
is a lookup key only, never an authorisation input.
|
||||
|
||||
**Emoji (correct cases, verified by execution)**
|
||||
ZWJ families `👨👩👧👦`; skin tone + ZWJ `👩🏽🚀`; VS16-then-ZWJ `🏳️🌈`;
|
||||
ZWJ-then-VS16 `🏴☠️`; keycaps `1️⃣` `#️⃣`; regional-indicator pairs `🇻🇳`;
|
||||
skin-tone modifiers `👍🏿`; `⭐` (the default emoji) classifies as emoji;
|
||||
plain `A` is refused. `len(out) > 20` boundary matches Telegram's 1-20.
|
||||
`parseEmoji` returns `(nil, nil)` for empty input and `/addsticker` falls back
|
||||
correctly while `/editsticker` refuses — the right asymmetry.
|
||||
|
||||
**Image pipeline**
|
||||
- `decodeBounded` checks `DecodeConfig` before allocating pixels; rejects
|
||||
>4096 per side and `<=0` dimensions as a `userError`.
|
||||
- `scaleToLongEdge` clamps the short edge to ≥1, so 4096×1 → 512×1: no
|
||||
zero-dimension image, no divide-by-zero.
|
||||
- Compression ladder is a fixed 3-element slice — cannot loop forever. The
|
||||
`data, err =` reassignment at `image.go:64` clobbers `data` on encode error,
|
||||
but the loop unconditionally reassigns it afterwards, and a loop encode error
|
||||
returns `(nil, encErr)`. No nil-with-nil-error return.
|
||||
- `toThumbnailPNG` offsets are always ≥0 because both scaled dimensions are ≤100;
|
||||
`draw.Draw`'s `sp` correctly maps `scaled.Bounds().Min`.
|
||||
- `downloadFile` bounds by `maxSourceBytes+1` on the reader itself rather than
|
||||
trusting `Content-Length`, and closes the body.
|
||||
|
||||
**Boundaries**
|
||||
- `slugRe` `^[a-z][a-z0-9_]{2,39}$` = 3-40 chars, matching `minSlugLen`/`maxSlugLen`;
|
||||
`__` and trailing `_` are checked separately as the comment states.
|
||||
- `makeSetName`'s budget cannot go negative for any legal Telegram username (≤32
|
||||
chars → suffix 36 → budget 28).
|
||||
- No `List` calls anywhere in the module; every lookup is a keyed `Get`. No N+1.
|
||||
- `senderID` rejects `SenderChat != nil`, so anonymous group admins cannot all
|
||||
collapse onto `GroupAnonymousBot`'s single user id.
|
||||
|
||||
**Error surfacing**
|
||||
- `replyErr` shows `userError` verbatim and replaces everything else with
|
||||
`genericFailure`; `downloadFile` discards the original error entirely rather
|
||||
than wrapping, so the bot token in `FileDownloadLink` cannot reach a log via
|
||||
`errors.Unwrap`/`%v`. `classify` inspects only error *types*.
|
||||
- No path returns `nil` where the caller assumes success. The two "log and
|
||||
continue" spots (`renamePack` commit `:550-554`, `adjustCount` fallback
|
||||
`sticker_handlers.go:87-92`, `:135-141`) both follow a *confirmed* Telegram
|
||||
success, so reporting success to the user is accurate about the pack even
|
||||
though the stored count/title may lag.
|
||||
|
||||
---
|
||||
|
||||
## Recommended actions
|
||||
|
||||
1. **H-1** — join the polling goroutine in `main` with a grace period > 5s before
|
||||
`srv.Shutdown`/`closeProvider`. Without this the whole `commitContext` design
|
||||
is decorative.
|
||||
2. **H-2** — adopt `chathelper.FetchContext` in the sticker handlers so a slow
|
||||
photo pipeline cannot swallow the user's reply.
|
||||
3. **M-1** — use the commit context for the *read* as well in `adjustCount`,
|
||||
`releaseSlug`, `dropPackRecord`, `dropPackRecordIfSet`.
|
||||
4. **M-2/M-3/M-4** — emoji table additions, tag-sequence binding, and the three
|
||||
invalid-cluster rejections.
|
||||
5. **L-1** — move the "already have a pack" pre-check above `resolveSource`
|
||||
(keeping it inside `lockUser` and before `reserveSlug`).
|
||||
6. **L-2** — make `lockUser` coverage uniform across the six `dropPackRecord`
|
||||
call sites, and take the lock before the `getPack` whose value feeds the API
|
||||
call in `handleAddSticker`/`handleRenamePack`.
|
||||
7. L-3, L-4, L-5, nit — at author's discretion.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Does Telegram permanently reserve a deleted set's short name? The code
|
||||
(`pack_handlers.go:455-458`) documents this as unverified and degrades
|
||||
gracefully either way, so it is not blocking — but it decides whether
|
||||
`releaseSlug` after a `/delpack` is meaningful or purely local bookkeeping.
|
||||
2. Does `SetStickerPositionInSet` error or clamp on an out-of-range position?
|
||||
Determines whether L-3 is a false success message or a non-issue.
|
||||
3. Is the sticker collection ever dropped or re-pointed in this deployment's
|
||||
operational runbook? That is the sole trigger for L-5 / table row 14.
|
||||
@@ -0,0 +1,234 @@
|
||||
# Security review — sticker module (uncommitted)
|
||||
|
||||
Lens: security / abuse resistance only. Style, naming, test coverage out of scope.
|
||||
Method: traced attacker-controlled inputs (command args, replied message, callback payload,
|
||||
image bytes) through every store write and every Telegram call that names a set. Third pass,
|
||||
after the takeover and name-burning fixes.
|
||||
|
||||
Verified environment facts used below:
|
||||
- `internal/telegram/client.go:26-31` — `WithNotAsyncHandlers()`, single worker: updates are
|
||||
processed strictly one at a time, inline on the polling goroutine.
|
||||
- `cmd/server/main.go:266-280` — provider auto-detect: `MONGO_URL` unset ⇒ **memory backend**,
|
||||
announced with `log.Warn` only. `"sticker": sticker.New` is registered unconditionally
|
||||
(`cmd/server/main.go:95`).
|
||||
- `go-telegram/bot@v1.21.0/raw_request.go:78-81` — the library redacts the token inside
|
||||
`*url.Error.URL` for API-call failures.
|
||||
- `models.CallbackQuery.Message` is a **value** `MaybeInaccessibleMessage` holding `*Message`,
|
||||
so `query.Message.Message` cannot nil-panic.
|
||||
|
||||
---
|
||||
|
||||
## HIGH — adoption is authorised by a record that is less durable than the object it protects
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:313-321` (adopt branch), `:138-166` (reserveSlug),
|
||||
`cmd/server/main.go:266-280` (backend selection).
|
||||
|
||||
The reservation is the *only* evidence that an existing Telegram set belongs to the caller —
|
||||
`GetStickerSet` exposes no owner. The reservation lives in the module's store; the sticker set
|
||||
lives on Telegram forever. Any event that empties the store while the sets survive re-opens the
|
||||
exact cross-user takeover the reservation was added to close, with no code change.
|
||||
|
||||
Exploitation (memory backend variant — reachable by omitting `MONGO_URL`, which only logs a Warn):
|
||||
|
||||
1. Victim V: `/newpack cool My Pack` → set `cool_by_<bot>` created, share link is public by design.
|
||||
2. Bot restarts (deploy, OOM, VM reboot). Memory store is empty; Telegram set untouched.
|
||||
3. Attacker A (any user, no pack) replies to any sticker with `/newpack cool Whatever`.
|
||||
- `reserveSlug` → no reservation exists → created for A.
|
||||
- `claimSlug` → no pack record for A → pending intent written.
|
||||
- `createOrAdopt` → `GetStickerSet("cool_by_<bot>")` returns **nil error** →
|
||||
`finishNewPack(..., adopted=true)` → A's record now owns V's set.
|
||||
4. A gains, via calls that carry no owner scoping at all:
|
||||
- `/delpack` → `DeleteStickerSet{Name}` — **destroys V's pack permanently** (no user_id param).
|
||||
- `/delsticker` replying to any sticker from V's public pack — `resolveOwned` passes because
|
||||
`pack.Name == st.SetName`; `DeleteStickerFromSet{Sticker}` takes only the file id.
|
||||
- `/renamepack` → `SetStickerSetTitle{Name,Title}` — also unscoped.
|
||||
V is simultaneously locked out: V's `/newpack cool` answers `slugTaken`, `/mypack` says no pack.
|
||||
|
||||
Same primitive without the memory backend: collection dropped, `MONGO_DATABASE` changed, module
|
||||
renamed, or a Mongo restore from a backup older than the newest sets.
|
||||
|
||||
Why the existing guards do not stop it: every guard (reservation owner check, `created` scoping,
|
||||
`ownsSet`, uniform refusals) reasons entirely inside the local store. When the store is empty the
|
||||
guards are all *satisfied*, and the adopt branch is by design the path that turns "a set exists
|
||||
under a name I hold" into ownership.
|
||||
|
||||
Fix direction (cheap and precise, no new state): adoption should require that the reservation
|
||||
**pre-dated this invocation**. `reserveSlug` already computes exactly that as `created`; plumb it
|
||||
into `createOrAdopt` and, when `created == true` and `GetStickerSet` succeeds, refuse with
|
||||
`slugTaken` (plus release the just-made reservation) instead of adopting. A genuine interrupted
|
||||
attempt always re-enters with `created == false` (its reservation was written by the earlier run),
|
||||
and a set cannot exist for a reservation first written microseconds ago in this same handler — so
|
||||
this has no false negatives, and the store-wipe path can no longer adopt anything.
|
||||
Secondly: refuse to build/register this module on a non-durable provider (or `log.Fatal` when
|
||||
`KV_PROVIDER=memory` and sticker is enabled) — the module creates permanent, globally visible
|
||||
Telegram objects and must not run on a store documented as "data lost on restart".
|
||||
|
||||
## MEDIUM — cleanup helpers read on the request context but write on a detached one; slugs leak permanently
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:179` (`releaseSlug` → `getSlugReservation(ctx, …)`),
|
||||
`:464` (`dropPackRecord` → `getPack(ctx, …)`), `:432` (`dropPackRecordIfSet` → `getPack(ctx, …)`).
|
||||
|
||||
`commitContext` (`state.go:59-61`) exists precisely because SIGTERM cancels `rootCtx` mid-handler.
|
||||
It is applied to the `Delete`/`Put` calls in these helpers but **not** to the reads that decide what
|
||||
to delete. The reads therefore fail exactly in the situation the detached write was designed for.
|
||||
|
||||
Scenario (ordinary deploy, no attacker needed): user presses the `/delpack` confirm button;
|
||||
`DeleteStickerSet` succeeds; SIGTERM lands (or the 10s `handlerTimeout` expires — the handler has
|
||||
already made 2-4 API calls by then).
|
||||
- In `dropPackRecordIfSet`, `getPack` fails → returns early → the record survives naming a set that
|
||||
no longer exists. Self-heals on the next command via `STICKERSET_INVALID`, so this half is benign.
|
||||
- In `dropPackRecord` (reached from any `isStickerSetMissing` path), `getPack` fails, the pack record
|
||||
is deleted anyway on the detached context, and `pack.Slug` is never known, so the reservation is
|
||||
never released. Result: a `slug:` document with no pack and no set behind it, held against every
|
||||
other user **forever** — nothing in the module can free it (`releaseSlug` needs both owner and
|
||||
slug, and the only record of the slug was just deleted). Manual DB surgery is the only recovery.
|
||||
- `handleNewPack:111-113` has the same shape: a deadline-exceeded bail calls `releaseSlug` with the
|
||||
dead context, so the "release only what this invocation created" repair silently no-ops.
|
||||
|
||||
Fix direction: derive the commit context once at the top of `releaseSlug` / `dropPackRecord` /
|
||||
`dropPackRecordIfSet` and use it for the read as well as the write. These reads are part of the
|
||||
commit, not part of serving the request.
|
||||
|
||||
## MEDIUM — uncancellable image work stalls every user of the bot
|
||||
|
||||
`internal/modules/sticker/image.go:35` (`maxDecodeDimension = 4096`), `:63-79` (the fallback ladder,
|
||||
which re-scales from the **full-size** source `img` on every rung), reached from `/addsticker`,
|
||||
`/newpack` and `/setpackicon`.
|
||||
|
||||
`toStickerPNG` takes no context and checks none, so `handlerTimeout` bounds nothing here, and
|
||||
handlers are strictly serialized (one worker), so this is a whole-bot stall, not a per-user one.
|
||||
Measured on this box (ARM64, Go 1.27) with a 4096×4096 PNG of random 8×8 blocks — 1,278,612 bytes,
|
||||
comfortably under the 2 MiB `maxSourceBytes` cap, and its 512px downscale is per-pixel noise, so
|
||||
every PNG encode overshoots `softMaxStickerBytes` and the full ladder runs:
|
||||
|
||||
```
|
||||
decode 175 ms
|
||||
512 DefaultCompression 553 ms → 787,362 B (> 512 KiB, ladder continues)
|
||||
512 BestCompression 41 ms → 773,200 B
|
||||
448 BestCompression 522 ms → 568,571 B
|
||||
384 BestCompression 513 ms → 411,425 B
|
||||
320 BestCompression 475 ms → 280,071 B
|
||||
TOTAL 2.28 s of uninterruptible CPU per message
|
||||
```
|
||||
|
||||
One user resending that image faster than every 2.3s keeps the single dispatch goroutine saturated;
|
||||
all other users' commands queue behind it. Peak live memory is also ~64 MB for the decoded source
|
||||
alone (as the comment at `image.go:29-34` acknowledges).
|
||||
|
||||
Fix direction: scale the ladder rungs from the already-downscaled 512 image instead of `img` (drops
|
||||
three of the four expensive 4096²→N CatmullRom passes); lower `maxDecodeDimension` to ~1536-2048
|
||||
(the target is 512px, so nothing above that adds quality); optionally take `ctx` and bail between
|
||||
rungs.
|
||||
|
||||
## LOW — `/newpack` pays for the image before the check that refuses the caller
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:68` (`resolveSource`) runs before the lock, before the
|
||||
"you already have a pack" pre-check at `:92`, and before `reserveSlug`. A user who already owns a
|
||||
pack can make the bot download up to 2 MB, run the full conversion above, and call
|
||||
`UploadStickerFile` on every `/newpack`, only to be refused by a single store read that could have
|
||||
run first. Not a new primitive (the same work is legitimately available via `/addsticker`), and the
|
||||
ordering is what keeps name-burning closed, so this is cost, not a hole. Moving the cheap
|
||||
`getPack` pre-check above `resolveSource` preserves the reserve-after-precheck invariant and removes
|
||||
the free work.
|
||||
|
||||
## LOW — `handleRenamePack` commits a record it read before taking the lock
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:531-551`: `getPack` runs at `:531`, the lock is taken at
|
||||
`:540`, and `commitPack` at `:550` writes the whole document (`Count`, `Pending`, `Name`, …) from
|
||||
that pre-lock read. `adjustCount:388-395` documents exactly why that is wrong and re-reads inside
|
||||
the lock; rename does not. `handleDelPack:32-83` likewise reads and writes the pending record with
|
||||
no lock at all. Neither is exploitable today — `WithNotAsyncHandlers` + one worker means no two
|
||||
handlers ever interleave — so the locks are currently decorative and these are latent regressions
|
||||
that surface the day async handlers or a second replica are introduced.
|
||||
|
||||
---
|
||||
|
||||
## Attacked and held
|
||||
|
||||
Callback path (`delpack_callback.go`), payload fully attacker-chosen:
|
||||
- **Address someone else's confirmation** — held: the store lookup key is
|
||||
`pendingDeleteKey(query.From.ID)` (`:108`); the payload id is never used to select *whose* action
|
||||
loads, only compared for equality at `:144`.
|
||||
- **Press a bystander's button in a group** — held: `msg.Chat.ID != action.ChatID || msg.ID !=
|
||||
action.MessageID` (`:137`) is evaluated *before* any side effect, so the bystander cannot even
|
||||
strip the victim's keyboard; `clearButton` only runs after that binding passes.
|
||||
- **Replay / double-press / two live confirmations** — held: deterministic per-user key
|
||||
(`pending_delete.go:64`) so a second `/delpack` supersedes the first, and the action is consumed
|
||||
with `pending.Delete` *before* `DeleteStickerSet` (`:159-167`).
|
||||
- **Stale press after `/delpack` + `/newpack`** — held: `dropPackRecordIfSet` (`pack_handlers.go:431`)
|
||||
re-checks `ownsSet` so a stale confirmation cannot erase the record of the *new* live pack.
|
||||
- **Forwarded copy of the prompt / inaccessible message** — held: `query.Message.Message == nil`
|
||||
guard at `:125` before any use, plus the message-id binding.
|
||||
- **Malformed payload** — held: `parseDeleteCallback` bounds length to 64, requires the prefix, and
|
||||
requires lowercase hex; ids are 12 random bytes from `crypto/rand`.
|
||||
- **Anonymous / bot senders** — held for the command side: `senderID` rejects `IsBot` and
|
||||
`SenderChat != nil` (`sender.go:28-38`), so the shared GroupAnonymousBot identity can never own or
|
||||
delete a pack; `query.From.ID == 0` is rejected on the callback side.
|
||||
|
||||
Reservation lifecycle — every `slug:` create/delete site enumerated:
|
||||
create at `reserveSlug:139` only; delete at `handleNewPack:112` (only when `created`),
|
||||
`resolveStaleIntent:300` (only after a positive `STICKERSET_INVALID` on the old name),
|
||||
`createOrAdopt:353` (only when `createRefused`), `dropPackRecord:479` (only after a confirmed
|
||||
`DeleteStickerSet` or a positive `STICKERSET_INVALID`). All four funnel through `releaseSlug`,
|
||||
which re-verifies the holder itself (`:187`) rather than trusting the caller, so a delete-by-name
|
||||
cross-user primitive does not exist. Unknown/transient errors change nothing
|
||||
(`createOrAdopt:325-331`, `resolveStaleIntent:303-307`) — verified by
|
||||
`TestNewPack_UnknownLookupErrorAborts` and `TestNewPack_ResumedReservationSurvivesABail`.
|
||||
The only leak I could construct is the cancelled-context one filed as MEDIUM above.
|
||||
|
||||
- **Name burning at zero API cost** — held: the existing-pack pre-check precedes `reserveSlug`
|
||||
(`:92-101`) and the `created` flag stops a bail from releasing a resumed reservation. Sustained
|
||||
burning also needs one Telegram account per name, since one pack per user is enforced by the
|
||||
create-only `PutVersioned(packKey, 0, …)` and `/delpack` returns the name to the pool.
|
||||
- **Key-space collision across the three views over one collection** — held:
|
||||
`slugRe = ^[a-z][a-z0-9_]{2,39}$` cannot emit `:` or a leading digit, so `"slug:"+slug` is
|
||||
disjoint from decimal `packKey` and from `"pending-delete:"+decimal`; `storage.validateKey`
|
||||
additionally rejects `/`, `.`/`..` and `__ns__`, and `provider.Collection("sticker")` isolates the
|
||||
module (`registry.go:151`). Callback prefixes are conflict-checked bidirectionally
|
||||
(`registry.go:219`).
|
||||
- **Mutating another user's stickers via a replied sticker** — held: `resolveOwned` compares the
|
||||
*stored* `Pack.Name` against `Sticker.SetName`, which is authored by Telegram and not settable by
|
||||
the sender; a copied sticker lands in the copier's own set with a new file id, and the original
|
||||
message still carries the victim's `set_name`.
|
||||
- **Enumeration** — held: `slugTaken` (`pack_handlers.go:23`) is byte-identical to the
|
||||
`PACK_SHORT_NAME_OCCUPIED` mapping in `apiRefusal` (`errors.go:62`), so "reserved in this bot" and
|
||||
"occupied on Telegram" are indistinguishable; `notOwnedRefusal` is the single answer for no-pack,
|
||||
pending-pack, and foreign-set. Raw Telegram descriptions never reach a reply — `replyAPIError`
|
||||
maps or genericises.
|
||||
- **Bot-token leakage** — held on every path I could reach: the download path discards the original
|
||||
error entirely (`errDownloadFailed`, `download.go:36-83`, no `%w` of the transport error) and logs
|
||||
only `classify(err)`, which inspects types and never formats the error; API-call failures have the
|
||||
token redacted inside `url.Error.URL` by the library itself; user replies echo only `userError`
|
||||
text (`state.go:95-101`). Replies are sent with no `ParseMode`, so a 64-char attacker-chosen title
|
||||
echoed in `/mypack` and the delete prompt cannot inject markup either.
|
||||
- **Error classification** — held: `isStickerSetMissing` requires both `bot.ErrorBadRequest` and the
|
||||
`STICKERSET_INVALID` code; `createRefused` is a separate positive-only list of
|
||||
request-validation codes and is used only to authorise undoing an intent + reservation. No path
|
||||
infers absence from a generic failure.
|
||||
- **Bounds ordering** — held except as noted in LOW: MIME allowlist and `FileSize` are checked before
|
||||
any byte is fetched (`photo.go:64,74`), `GetFile.FileSize` is re-checked server-side, the body is
|
||||
read through `LimitReader(max+1)` with an explicit overflow check, `DecodeConfig` bounds dimensions
|
||||
before any pixel buffer exists, and the 20-emoji cap is enforced before the API call.
|
||||
|
||||
---
|
||||
|
||||
## Recommended actions
|
||||
|
||||
1. Gate adoption on `created == false` (reservation pre-dated this `/newpack`), and refuse to run
|
||||
this module on a non-durable store. — HIGH
|
||||
2. Use `commitContext` for the reads inside `releaseSlug` / `dropPackRecord` /
|
||||
`dropPackRecordIfSet`. — MEDIUM
|
||||
3. Scale the fallback ladder from the 512px image and lower `maxDecodeDimension`. — MEDIUM
|
||||
4. Move the existing-pack pre-check above `resolveSource` in `/newpack`. — LOW
|
||||
5. Re-read inside the lock in `handleRenamePack` (match `adjustCount`), or state in the module doc
|
||||
that serialization is guaranteed by the dispatcher and the locks are belt-and-braces. — LOW
|
||||
|
||||
## Unresolved
|
||||
|
||||
- `go test ./internal/modules/sticker/...` failed once on its first (uncached) run — tail showed two
|
||||
`sticker_newpack_lookup … upstream is unhappy` ERROR lines then `FAIL` — and then passed 25+
|
||||
consecutive runs including `-count=8` and `-race`, and under 8-way CPU load. Not reproduced, not a
|
||||
security finding, but flagging it for whoever owns the tests: the suspects are
|
||||
`TestNewPack_UnknownLookupErrorAborts` / `TestNewPack_ResumedReservationSurvivesABail`.
|
||||
- Whether Telegram permanently reserves the short name of a deleted set (plan R11) is still
|
||||
unverified. The code handles both answers correctly, so this is an open fact, not a defect.
|
||||
@@ -0,0 +1,411 @@
|
||||
# Test / harness / wiring review — sticker module
|
||||
|
||||
Reviewer lens: test quality, harness correctness, integration & wiring.
|
||||
Method: read all sources, then **mutation-tested 22 production mutations** against the
|
||||
suite. Security exploitation and deep state-machine correctness owned by other reviewers.
|
||||
|
||||
Verdict: test quality is **high** — 19 of 22 mutations killed, and the ownership gates,
|
||||
callback binding, self-heal, panic barrier and count bookkeeping are all genuinely pinned.
|
||||
Two rounds previously claimed "all new tests non-vacuous"; that is **refuted in three
|
||||
places**: the `created`/reservation-release machinery, two `/addsticker` emoji tests, and
|
||||
the module-registration wiring. None is a bug in shipped behaviour today; all are real
|
||||
holes that would let a future regression land green.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mutation-test results
|
||||
|
||||
Every mutation applied to production source only, run, then reverted. Restoration verified
|
||||
by md5 + `diff -r` (see §7).
|
||||
|
||||
| # | Mutation applied | File | Guarding test(s) | Result |
|
||||
|---|---|---|---|---|
|
||||
| M1 | Delete the `ownsSet` gate from `resolveOwned` | resolve.go:116 | `TestResolveOwned_RefusalsAreIdentical` | **KILLED** |
|
||||
| M2 | `!found \|\| pack.Pending` → `!found` | resolve.go:113 | `TestResolveOwned_PendingRefusesIdentically` | **KILLED** |
|
||||
| M3 | Disable foreign-holder refusal in `reserveSlug` | pack_handlers.go:159 | `TestNewPack_CannotSeizeAnotherUsersPack`, `..._ForeignReservationRefusedBeforeAnyAPICall` | **KILLED** |
|
||||
| M4 | Disable reservation-owner proof in `resolveStaleIntent` | pack_handlers.go:263 | `TestNewPack_StaleIntentCannotAdoptForeignName` | **KILLED** |
|
||||
| **M5** | **`if created { releaseSlug }` → never release** | **pack_handlers.go:111** | *(none)* | **SURVIVED** |
|
||||
| **M6** | **Delete the pre-reservation quota check entirely** | **pack_handlers.go:92-99** | *(none)* | **SURVIVED** |
|
||||
| **M7** | **`reserveSlug` resumed path returns `created=true`** | **pack_handlers.go:165** | *(none)* | **SURVIVED** |
|
||||
| M8 | `dropPackRecordIfSet` → `dropPackRecord` (blind by-owner delete) | delpack_callback.go:178 | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` | **KILLED** |
|
||||
| M9 | Disable chat/message binding check | delpack_callback.go:137 | `..._RejectsWrongBinding` (2 subtests), `..._BystanderCannotTouchAnotherUsersPrompt` | **KILLED** |
|
||||
| M10 | Remove consume-before-destructive-call | delpack_callback.go:159 | `TestDelPackCallback_SecondPressIsInert` | **KILLED** |
|
||||
| M11 | Disable expiry check | delpack_callback.go:149 | `TestDelPackCallback_RejectsExpired` | **KILLED** |
|
||||
| M12b | `dropPackRecord` never releases the slug | pack_handlers.go:478 | `TestSelfHeal_ReleasesTheName`, `TestDelPackCallback_ReleasesTheName` | **KILLED** |
|
||||
| M13 | `isStickerSetMissing` → `err != nil` (transient read as "gone") | errors.go:163 | 4 tests incl. both `_TransientErrorKeepsRecord` | **KILLED** |
|
||||
| M14 | Remove the negative-count floor | pack_handlers.go:407 | `TestDelSticker_CountFlooredAtZero` | **KILLED** |
|
||||
| M15 | Invert emoji precedence (replied beats explicit) | sticker_handlers.go:283 | `TestAddSticker_HappyPath`, `..._EmojiPrecedence/explicit_wins` | **KILLED** |
|
||||
| **M16** | **Early-return before `AddStickerToSet` (no API call at all)** | **sticker_handlers.go:292** | *(none — see F2)* | **SURVIVED** |
|
||||
| M17 | Drop the inherit-from-replied-sticker fallback | sticker_handlers.go:283 | `..._EmojiPrecedence/inherits_from_replied_sticker` | **KILLED** |
|
||||
| M18 | Remove the command panic barrier | dispatcher.go:75 | `TestInstall_CommandPanicIsContained` (binary would die) | **KILLED** |
|
||||
| M19 | Remove the command-hook panic barrier | dispatcher.go:83 | `TestInstall_CommandHookPanicIsContained` | **KILLED** |
|
||||
| M20 | Callback barrier `onPanic` → nil (stop answering the query) | dispatcher.go:104 | `TestInstall_CallbackPanicIsContainedAndAnswered` | **KILLED** |
|
||||
| M21 | `claimSlug` `PutVersioned(…,0,…)` → `Put` | pack_handlers.go:216 | `TestNewPack_DifferentSlugAdoptsExistingSet` | **KILLED** |
|
||||
| M22 | `reserveSlug` `PutVersioned(…,0,…)` → `Put` | pack_handlers.go:139 | `TestNewPack_CannotSeize…`, `..._ForeignReservation…` | **KILLED** |
|
||||
| **W1** | **Delete `"mypack": ""` from `expectedParameters`** | **cmd/server/command_menu_test.go:76** | *(none — see F4)* | **SURVIVED** |
|
||||
| **W2** | **Unregister `sticker` from `factories()` (import removed too)** | **cmd/server/main.go:95** | *(none — see F5)* | **SURVIVED** |
|
||||
| W3 | Corrupt a non-empty expectation (`"newpack": "WRONG"`) — control | command_menu_test.go:75 | `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` | **KILLED** |
|
||||
|
||||
Survivors M5/M6/M7 and W2 were each re-run against the **full** sticker suite / **full repo
|
||||
suite** (`./...`), not just a `-run` subset. All still survived.
|
||||
|
||||
---
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### F1 — HIGH — the `created` reservation-release machinery has zero test coverage
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:106-115` and `:138-165`.
|
||||
|
||||
Both directions of the `created` flag survive mutation:
|
||||
|
||||
- **M5** (never release): survived the full suite.
|
||||
- **M7** (always release, even a resumed reservation): survived the full suite.
|
||||
|
||||
Both branches are reachable. I proved it with two temporary probe tests (since removed):
|
||||
|
||||
- *Release-on-bail is real*: `/newpack newslug` while a pending record for `oldslug`
|
||||
exists and the old set resolves → `reserveSlug` writes `newslug` (`created=true`),
|
||||
`claimSlug`→`resolveStaleIntent` adopts `oldslug` and returns `done=true`, so
|
||||
`releaseSlug(newslug)` must run. Under M5 the probe failed with
|
||||
`newslug still reserved with no pack behind it - name burned`. Reservations are global
|
||||
and permanent, so this is a per-invocation namespace leak.
|
||||
**`TestNewPack_DifferentSlugAdoptsExistingSet` (pack_handlers_test.go:111) walks exactly
|
||||
this path and asserts nothing about `newslug`.** One added line closes it.
|
||||
- *Not-releasing-a-resumed-reservation is real*: reserve `oldslug` for the caller, pending
|
||||
record under a different slug, `getStickerSet` fails unclassifiably → `resolveStaleIntent`
|
||||
bails `done=true`. Under M7 that probe failed: the caller's pre-existing reservation was
|
||||
destroyed, handing the name to the next asker while the set may still exist.
|
||||
|
||||
**`TestNewPack_ResumedReservationSurvivesABail` (pack_handlers_test.go:573) does not test
|
||||
what its name says.** In that test `claimSlug` returns `done=false` (same-slug resume), so
|
||||
the `if created` branch is never reached; the bail happens later in `createOrAdopt`, which
|
||||
never consults `created`. M7 survives it. It is a duplicate of
|
||||
`TestNewPack_UnknownLookupErrorAborts` wearing a different name.
|
||||
|
||||
Fix: assert `newslug` is unreserved in `TestNewPack_DifferentSlugAdoptsExistingSet`, and
|
||||
re-point `TestNewPack_ResumedReservationSurvivesABail` at a bail inside
|
||||
`claimSlug`/`resolveStaleIntent` (a pending record under a *different* slug + a 500 from
|
||||
`getStickerSet` reaches it).
|
||||
|
||||
### F2 — MEDIUM — two `/addsticker` emoji tests pass when no sticker is added at all
|
||||
|
||||
`handlers_test.go:134-159` (`TestAddSticker_EmojiPrecedence`) and `:163-178`
|
||||
(`TestAddSticker_FallsBackToDefaultEmoji`).
|
||||
|
||||
Both use the pattern:
|
||||
|
||||
```go
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method == "addStickerToSet" && !strings.Contains(call.Form["sticker"], tc.want) {
|
||||
t.Errorf(...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Zero matching calls ⇒ zero assertions ⇒ pass. **M16** confirms it: an early `return` placed
|
||||
before `b.AddStickerToSet` leaves both tests green. They are half-live (M15/M17 kill
|
||||
individual subtests via the emoji value) but they do not guard "a sticker was added".
|
||||
|
||||
`TestAddSticker_HappyPath:108` has the right guard (`countMethod(...) != 1` + `Fatalf`).
|
||||
Add the same two lines to both tests. Same latent shape at `resolve_test.go` — no, those
|
||||
use explicit `countMethod` comparisons and are fine.
|
||||
|
||||
### F3 — MEDIUM — `docs/sticker-packs.md` contradicts the reservation lifecycle it describes
|
||||
|
||||
`docs/sticker-packs.md:99-101`:
|
||||
|
||||
> "A name is claimed only when a pack is actually created, and it is released when that
|
||||
> pack is deleted … A `/newpack` that is refused claims nothing."
|
||||
|
||||
The first clause is false and inverts the module's central safety property. `reserveSlug`
|
||||
writes the reservation **before Telegram is touched** — that write-ahead claim is precisely
|
||||
what makes adoption safe, and `pack.go:236-239` plus `pack_handlers.go:120-137` say so at
|
||||
length. A name is therefore held while a `/newpack` is merely *pending*, and
|
||||
`TestNewPack_UnknownLookupErrorAborts:181` asserts the reservation **must survive** when no
|
||||
pack was created. An interrupted `/newpack` holds its name indefinitely with no pack behind
|
||||
it — the doc tells a reader the opposite.
|
||||
|
||||
The second clause is also too strong: a *classified* refusal releases, but an unknown
|
||||
`getStickerSet`/`createNewStickerSet` failure deliberately keeps both intent and
|
||||
reservation (`pack_handlers.go:325-331, 347-356`).
|
||||
|
||||
Everything else in the doc checks out against source: 512px long edge / 100×100 thumbnail
|
||||
(`image.go:23,25`), 4096px cap (`maxDecodeDimension = 4096`), 2 MB (`maxSourceBytes`),
|
||||
120 stickers, 1–20 emoji, 10-minute confirm TTL, 10-second handler deadline, slug rules,
|
||||
`MODULES` semantics, uniform ownership refusals.
|
||||
|
||||
### F4 — LOW — `command_menu_test` does not verify that a command has an expectation
|
||||
|
||||
`cmd/server/command_menu_test.go:104`: `got != expectedParameters[command.Name]`. A missing
|
||||
map key yields `""`, so any public command with empty `Parameters` that nobody added to the
|
||||
map passes silently. **W1** confirms: deleting `"mypack": ""` changes nothing. Four of the
|
||||
nine new entries (`mypack`, `delsticker`, `setpackicon`, `delpack`) are therefore
|
||||
decorative. The test does not verify what its name ("AllPublicCommandsHaveSafeMetadata")
|
||||
promises for parameterless commands.
|
||||
|
||||
Fix: `want, ok := expectedParameters[command.Name]; if !ok { t.Errorf("no expectation for /%s") }`.
|
||||
That also turns the test into the missing registration guard for F5.
|
||||
|
||||
### F5 — MEDIUM — nothing pins that the sticker module is registered at all
|
||||
|
||||
**W2**: with both the import and `"sticker": sticker.New` removed from `cmd/server/main.go`,
|
||||
`go test ./...` is **fully green** — all 25 packages pass, including
|
||||
`internal/modules/sticker` (its tests construct `state` directly and never go through
|
||||
`factories()`). A bad merge or rebase that drops the factory line ships a bot with none of
|
||||
the nine commands and a green CI.
|
||||
|
||||
`command_menu_test.go` only iterates whatever `reg.PublicCommands()` returns, so an absent
|
||||
module is invisible to it. Cheapest fix is F4's `ok` check, which makes the map an
|
||||
inventory rather than a lookup.
|
||||
|
||||
### F6 — LOW — `/newpack` documented but unregistered `MODULES` default changed silently
|
||||
|
||||
`.env.example` flips `MODULES=` (empty ⇒ load everything) to an explicit 12-module list.
|
||||
The list matches `factories()` exactly (verified key by key), so no module is dropped today.
|
||||
But it is now a hand-maintained duplicate of `factories()` with no test tying the two
|
||||
together — the next module added will be silently excluded for anyone starting from the
|
||||
template. Worth a comment pointing at `factories()`, or a test.
|
||||
|
||||
---
|
||||
|
||||
## 3. Harness review — `internal/testutil/recording_bot.go`
|
||||
|
||||
All four questions checked empirically. **The harness changes are correct and
|
||||
backwards-compatible.**
|
||||
|
||||
- **`FailMethodCode` produces the real sentinels — confirmed.** The library switches on
|
||||
`r.ErrorCode` decoded from the *body* and ignores the HTTP status
|
||||
(`go-telegram/bot@v1.20.0/raw_request.go:102-131`). `FailMethodCode` marshals
|
||||
`{"ok":false,"error_code":…,"description":…}`, so `errors.Is(err, bot.ErrorBadRequest)`
|
||||
holds. `recording_bot_test.go:113` pins this, and `:130` pins the negative contrast for
|
||||
bare `FailMethod`. The doc comment's `raw_request.go:103-125` citation is accurate.
|
||||
- **`StubMethod` / `FailMethod` precedence is correct and tested.** `handle()` checks
|
||||
`shouldFail` before `hasStub` (`:200-209`); `TestRecordingBot_FailureWinsOverStub` covers it.
|
||||
- **`Reset()` is coherent and unchanged for existing callers.** It clears `calls` only —
|
||||
which is exactly what it did before this changeset; the diff only adds a doc comment
|
||||
explaining it. `nextMessageID` is deliberately not reset (IDs stay unique across a
|
||||
Reset), which no caller depends on. All 13 other packages that use the harness call only
|
||||
`Reset()`; **no module outside `sticker` uses `FailMethod`, `FailMethodCode` or
|
||||
`StubMethod`**, so the new precedence rule cannot affect them.
|
||||
- **Message IDs start at 1, not 0.** `handle()` increments *before* assigning
|
||||
(`:192-195`), so the first `sendMessage` returns `message_id: 1`. This matters because
|
||||
production rejects a binding with `action.MessageID == 0` (`delpack_callback.go:137`) —
|
||||
I verified the full round trip (`/delpack` → press the button the handler itself
|
||||
produced → `deleteStickerSet` fires exactly once). No collision. *(Observation only:
|
||||
no test in the suite actually performs that round trip; the pieces are covered
|
||||
separately.)*
|
||||
|
||||
**One real harness concern (MEDIUM):**
|
||||
|
||||
The multipart-parse tolerance (`:180`) is justified — I confirmed `getMe` genuinely fails
|
||||
with `multipart: NextPart: EOF` and `ContentLength=-1`, so the old code made every
|
||||
parameterless method untestable. But the tolerance is **wider than the justification**: I
|
||||
posted a deliberately malformed multipart body (`Content-Type: multipart/form-data;
|
||||
boundary=zzz` with non-multipart content) to `/sendMessage` and the harness answered
|
||||
**HTTP 200** and recorded `{Method:sendMessage Form:map[]}`. A real Telegram would 400 it.
|
||||
|
||||
Impact is bounded — the bot library always builds well-formed multipart, so production
|
||||
cannot realistically emit garbage. The live risk is **masking**: any test asserting a form
|
||||
field is *absent* would falsely pass if the whole form silently failed to parse. Such
|
||||
assertions already exist outside this module, e.g.
|
||||
`internal/modules/stock/dividend_flow_test.go:115,139` (`calls[1].Form["reply_markup"] != ""`).
|
||||
|
||||
Suggested narrowing: tolerate only the empty-body case (`r.ContentLength <= 0`, or the
|
||||
`NextPart: EOF` shape) and keep the 400 for genuinely malformed bodies; or record a
|
||||
`ParseFailed bool` on `SentCall` so a masked parse is visible in `dumpCalls()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Untested error branches
|
||||
|
||||
Prioritised by blast radius if a bug landed there. `✗` = no test reaches the branch.
|
||||
|
||||
**`pack_handlers.go` — state-corrupting or namespace-leaking:**
|
||||
|
||||
- `:111` `if created { releaseSlug }` — ✗ **both directions** (F1). Name-burn / name-theft.
|
||||
- `:178-196` `releaseSlug`'s own cross-user ownership guard (`held.OwnerID != ownerID`) — ✗.
|
||||
Defence-in-depth against a bad call site, with zero coverage; a caller passing the wrong
|
||||
owner would be caught only here.
|
||||
- `:325-331` `createOrAdopt` default branch is covered, but **`:347-356` create failing with
|
||||
an *unclassifiable* error** (intent + reservation must both survive) — ✗. This is the exact
|
||||
mirror of `TestNewPack_UnknownLookupErrorAborts` and is the higher-risk half, since a
|
||||
wrong answer here strands a slug whose set may exist.
|
||||
- `:396-413` `adjustCount`'s `!found` → `storage.ErrNotFound` path, and both handler
|
||||
fallbacks that consume it (`sticker_handlers.go:308-314`, `:352-359`) — ✗. These
|
||||
synthesise a count for the reply; a bug shows the user a wrong number.
|
||||
- `:147-158` `reserveSlug` conflict-but-unreadable (`getErr != nil || !found` → treat as
|
||||
taken) — ✗. Comment calls out that guessing the other way *is* the takeover.
|
||||
- `:220-229` `claimSlug` non-conflict store error / re-read failure — ✗.
|
||||
- `:258-272` `resolveStaleIntent` reservation-read error, and both `Put` failures
|
||||
(`:267`, `:296`) — ✗.
|
||||
- `:283-286` adopt-commit failure — ✗.
|
||||
- `:73-77` `resolver.resolve` (GetMe) failure, `:78-81` `makeSetName` failure through the
|
||||
handler — ✗ (`makeSetName` is unit-tested, the handler branch is not).
|
||||
- `:92-94` pre-check store error, `:495-499` `/mypack` store error, `:531-535` /`renamepack`
|
||||
store error, `:542-547` `/renamepack`'s `isStickerSetMissing` self-heal — ✗.
|
||||
- `:366-369`, `:550-554` commit failures — ✗ (both are best-effort by design).
|
||||
|
||||
**`delpack_callback.go`:**
|
||||
|
||||
- `:144-147` `action.ID != id` → clear button + "replaced by a newer /delpack" — ✗.
|
||||
`TestDelPack_SecondPromptSupersedesTheFirst` is rejected earlier, at the *binding* check
|
||||
(`:137`), so this branch and its `clearButton` side effect never execute in tests.
|
||||
- `:97-100` malformed callback data through the handler — ✗ (`parseDeleteCallback` is
|
||||
unit-tested at `delpack_callback_test.go:84` for the happy case only; no test feeds
|
||||
over-length, non-hex or wrong-prefix data to `handleDelPackCallback`).
|
||||
- `:105-107` `query.From.ID == 0`, `:118-120` `From.ID != action.OwnerID` — ✗ (both
|
||||
unreachable given the owner-keyed lookup; defence in depth).
|
||||
- `:113-116` pending-store read error, `:159-165` both consume-delete failure branches — ✗.
|
||||
- `:32-36` `/delpack` store error and `:37-39` `!found` ("you don't have a pack") — ✗.
|
||||
- `:70-73` `SendMessage` failure — ✗. Note this is the one path in the module that returns
|
||||
a **raw** API error to the dispatcher rather than a `userError`/generic reply.
|
||||
- `:80-83` pending `Put` failure — ✗. Leaves a live button with no server-side action.
|
||||
|
||||
**`resolve.go`:**
|
||||
|
||||
Well covered. Only `:108-111` `getPack` store error is ✗. `resolveSource`'s `replied == nil`
|
||||
refusal (`:51-53`) is ✗ directly, though `resolveOwned`'s equivalent is tested.
|
||||
|
||||
**Whole-feature gap — Phase 5 photo pipeline has no integrated coverage.** Every test
|
||||
message is built by `stickerReply()`, which always sets `Sticker`. Grep confirms no test
|
||||
constructs a `Photo:` / `Document:` reply and feeds it to a handler. Consequently
|
||||
`resolvePhotoSource` (`photo.go:342`) is never executed, and **`handleSetPackIcon`
|
||||
(`setpackicon.go:174`) is reached only by `TestHandlers_RefuseAnonymousSenders`, which
|
||||
returns at the sender check before doing anything** — its download → resize →
|
||||
`SetStickerSetThumbnail` → self-heal body is entirely untested. The pieces (`photoFileID`,
|
||||
`downloadFile`, `toStickerPNG`, `toThumbnailPNG`) are individually well tested; the wiring
|
||||
between them is not. `StubMethod("uploadStickerFile", …)` now makes this testable — that is
|
||||
what the harness change was for.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test isolation, races, lint
|
||||
|
||||
- `go test -race ./internal/modules/... ./internal/testutil/... ./cmd/server/...` — **clean,
|
||||
exit 0, 0 `DATA RACE`**, all 17 packages ok.
|
||||
- `golangci-lint run` on the four changed packages — **0 issues**.
|
||||
- No shared mutable fixtures: every test builds its own `newTestState()` over a fresh
|
||||
`storage.NewMemoryProvider()` and its own `RecordingBot`. No ordering dependence found.
|
||||
- `syncBuffer` (dispatcher_panic_test.go:33) correctly mutex-guards the log sink for the
|
||||
detached-goroutine test, and `waitForLog` polls rather than sleeping. Good.
|
||||
- Two globals are mutated without isolation in `dispatcher_panic_test.go`: `log.SetDefault`
|
||||
(restored via defer) and `metrics` counters (`metrics.Flush()` at :116, never reset).
|
||||
Harmless today — nothing runs in parallel and no other test in `modules_test` asserts on
|
||||
error counters — but the metrics assertion at :130 would become order-dependent if one
|
||||
ever did. Worth a note, not a change.
|
||||
- `seedPack` correctly seeds the slug reservation alongside the pack record
|
||||
(handlers_test.go:45-50), and `seedInterrupted` does the same
|
||||
(pack_handlers_test.go:387-398). Both carry a comment explaining that seeding the record
|
||||
alone builds a state production cannot reach. This is the right instinct and it is why
|
||||
M12b/M22 kill cleanly.
|
||||
|
||||
## 6. Wiring & plan accuracy
|
||||
|
||||
**Registered correctly:** all 9 commands appear in `sticker.go:22-82` with
|
||||
`VisibilityPublic`, descriptions, and `Parameters` matching
|
||||
`docs/command-parameter-conventions.md` (the new `<name...>` "required remaining text" row
|
||||
is a genuine addition, used by `<title...>`). All 9 are menu-described and
|
||||
parameter-documented. README table and `docs/sticker-packs.md` list the same 9.
|
||||
|
||||
**Callback prefix is unique.** `callbackPrefix = "sticker_pack:"` vs the only other
|
||||
callback in the repo, `stock`'s `"stock_div:"`. `registry.go:218-220` enforces this
|
||||
**bidirectionally** (`HasPrefix` both ways), so the check is real, not nominal.
|
||||
|
||||
**Plan accuracy** — `plan.md` status is honestly `partial`, and all 16 unchecked boxes are
|
||||
live-Telegram smoke tests plus the unresolved R11 (does Telegram reserve deleted short
|
||||
names). No inflated completion. Two checked boxes are contradicted by shipped code:
|
||||
|
||||
- `phase-03:245` — "`/newpack` where `GetStickerSet` fails with a non-missing error aborts,
|
||||
**deletes the pending record**, and never calls `CreateNewStickerSet`" is `[x]`, but the
|
||||
shipped code deliberately **keeps** both intent and reservation
|
||||
(`pack_handlers.go:325-331`), and `TestNewPack_UnknownLookupErrorAborts:174-183` asserts
|
||||
that. The file's own superseding note at `phase-03:52-55` says this step is wrong — the
|
||||
checkbox was ticked against the superseded text.
|
||||
- `phase-03:225` — "`sticker.go` factory registering **4 commands** + callback prefix" is
|
||||
`[x]`; the shipped factory registers 9. Stale phase-scoped wording, harmless.
|
||||
|
||||
**`internal/modules/wordle/lookup_test.go`** — confirmed a pure `gofmt` alignment change.
|
||||
The 8 map keys and all 8 values are byte-identical; only leading whitespace inside the
|
||||
composite literal moved (the longest key `" crane "` no longer forces extra padding).
|
||||
No behaviour change.
|
||||
|
||||
## 7. Final state
|
||||
|
||||
Every mutated file restored and verified byte-identical against a pre-review backup:
|
||||
|
||||
```
|
||||
md5sum -c backup/md5.txt → all 31 files OK (no mismatches)
|
||||
diff -r backup/cmdserver cmd/server → cmd/server IDENTICAL
|
||||
diff -r backup/testutil internal/testutil → testutil IDENTICAL
|
||||
diff -r backup/sticker internal/modules/sticker → sticker IDENTICAL
|
||||
diff backup/dispatcher.go internal/modules/dispatcher.go → dispatcher IDENTICAL
|
||||
```
|
||||
|
||||
Three temporary probe test files were created and removed (`zz_probe_test.go`,
|
||||
`zz_probe2_test.go` in `sticker`; `zz_probe_test.go` in `testutil`); none remain.
|
||||
|
||||
`git status --short`:
|
||||
|
||||
```
|
||||
M .env.example
|
||||
M README.md
|
||||
M cmd/server/command_menu_test.go
|
||||
M cmd/server/main.go
|
||||
M docs/command-parameter-conventions.md
|
||||
M go.mod
|
||||
M go.sum
|
||||
M internal/modules/dispatcher.go
|
||||
M internal/modules/wordle/lookup_test.go
|
||||
M internal/testutil/recording_bot.go
|
||||
M internal/testutil/recording_bot_test.go
|
||||
M plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md
|
||||
M plans/260824-1051-sticker-pack-module/plan.md
|
||||
?? docs/sticker-packs.md
|
||||
?? internal/modules/dispatcher_panic_test.go
|
||||
?? internal/modules/sticker/
|
||||
?? plans/reports/correctness-review-260825-1515-sticker-module.md
|
||||
?? plans/reports/security-review-260825-1515-sticker-module.md
|
||||
```
|
||||
|
||||
Identical to the state at review start, plus the two peer reviewers' reports and this file.
|
||||
|
||||
`go test ./...` — **all 25 packages ok**, zero failures.
|
||||
`go test -race` on all module + testutil + cmd/server packages — **ok, 0 data races**.
|
||||
`golangci-lint run` on changed packages — **0 issues**.
|
||||
|
||||
## 8. Recommended actions
|
||||
|
||||
1. **(F1, high)** Assert `newslug` is released in `TestNewPack_DifferentSlugAdoptsExistingSet`;
|
||||
re-point `TestNewPack_ResumedReservationSurvivesABail` at a bail inside
|
||||
`claimSlug`/`resolveStaleIntent` so it kills M7. Two tests, ~6 lines.
|
||||
2. **(F5 + F4, medium)** Add the `want, ok := expectedParameters[name]` presence check in
|
||||
`command_menu_test.go`. Closes both the decorative-entry hole and the missing
|
||||
registration guard in one edit.
|
||||
3. **(F2, medium)** Add the `countMethod(rb, "addStickerToSet") != 1` + `Fatalf` guard to
|
||||
`TestAddSticker_EmojiPrecedence` and `TestAddSticker_FallsBackToDefaultEmoji`.
|
||||
4. **(F3, medium)** Correct `docs/sticker-packs.md:99-101` to describe the write-ahead
|
||||
reservation: the name is claimed *before* Telegram is called and is held while an
|
||||
attempt is pending; only a positively-classified refusal or a confirmed delete releases it.
|
||||
5. **(§3, medium)** Narrow the multipart tolerance to the empty-body case, or surface a
|
||||
`ParseFailed` flag on `SentCall`.
|
||||
6. **(§4, medium)** Add one integrated photo test (`StubMethod("uploadStickerFile", …)`)
|
||||
and one `handleSetPackIcon` happy path — the largest untested surface in the module.
|
||||
7. **(§4, low)** Cover `createOrAdopt`'s unclassifiable-create-error branch and
|
||||
`delpack_callback.go:144` (`action.ID != id`).
|
||||
8. **(§6, low)** Untick or correct `phase-03:245`; fix the "4 commands" wording at `:225`.
|
||||
|
||||
## 9. Unresolved questions
|
||||
|
||||
- `phase-06` leaves R11 (does Telegram permanently reserve a deleted short name?) open, and
|
||||
`dropPackRecord`'s comment reasons about it both ways. Nothing here can settle it without
|
||||
a live token; the code's behaviour is safe under either answer, so it is correctly
|
||||
deferred to the manual smoke list.
|
||||
- Is the `.env.example` switch from empty-`MODULES` to an explicit list intended to change
|
||||
deployed behaviour, or only to document intent? If deployments copy the template, adding
|
||||
a future module will require an `.env` edit that nothing warns about.
|
||||
@@ -0,0 +1,260 @@
|
||||
# Sticker module — round 5 adversarial verification
|
||||
|
||||
Commit `e81b1b7` ("fix(sticker): never adopt an existing pack"), branch
|
||||
`feature/sticker-pack-module`, Go 1.27, golangci-lint v2.13.1.
|
||||
|
||||
**Verdict: DO_NOT_MERGE.** The adoption class is genuinely closed — every route
|
||||
to a committed `Pack` record naming a foreign set was enumerated and executed,
|
||||
and all of them refuse. But the round-5 pattern repeated in the *other*
|
||||
direction: the commit correctly identified that `DeleteStickerSet` is a
|
||||
cross-user primitive keyed by set name, added a guard for one way of reaching
|
||||
it, and left a second way open. A stale `/delpack` confirmation destroys
|
||||
whichever user holds that name at press time.
|
||||
|
||||
---
|
||||
|
||||
## C1 (Critical) — a stale `/delpack` confirmation deletes a re-issued name's pack
|
||||
|
||||
`internal/modules/sticker/delpack_callback.go:177-185`
|
||||
|
||||
```go
|
||||
if current, found, err := getPack(ctx, s.store, action.OwnerID); err != nil {
|
||||
...
|
||||
} else if found && current.Pending && ownsSet(current, action.SetName) {
|
||||
// refuse
|
||||
}
|
||||
// falls through to DeleteStickerSet(action.SetName)
|
||||
```
|
||||
|
||||
The re-check is **negative** — it blocks exactly one bad state (`Pending`) — where
|
||||
it needed to be **positive**: only proceed when the record still authorises this
|
||||
delete. `!found` and "record now names a different set" both fall through to the
|
||||
destructive call. `dropPackRecordIfSet` performs precisely the right check
|
||||
(`found && ownsSet`), but it runs *after* `DeleteStickerSet`, so it protects the
|
||||
local record and not the set.
|
||||
|
||||
### Reproduction (executed; all steps are ordinary public commands)
|
||||
|
||||
Test `TestProbe_StaleConfirmationDeletesReissuedName`, run against
|
||||
`internal/modules/sticker`:
|
||||
|
||||
| # | Actor | Command | Effect |
|
||||
|---|-------|---------|--------|
|
||||
| 1 | U | `/newpack mypack Mine` | committed record + reservation `mypack` |
|
||||
| 2 | U | `/delpack`, **do not press** | `PendingDelete{SetName: mypack_by_testbot}` stored, TTL 10 min |
|
||||
| 3 | U | `/delsticker` down to zero, then any command | set gone at Telegram → `STICKERSET_INVALID` → `dropPackRecord` drops the record **and releases the reservation**. The unpressed confirmation is untouched — no `dropPackRecord` path clears `s.pending`. |
|
||||
| 4 | V | `/newpack mypack Victim Pack` | reservation free, `GetStickerSet` missing → V legitimately creates and owns `mypack_by_testbot` |
|
||||
| 5 | U | presses the button from step 2 | binding OK, not expired, re-check sees `found=false` → **`deleteStickerSet name=mypack_by_testbot`** |
|
||||
|
||||
Observed output:
|
||||
|
||||
```
|
||||
step3 self-heal: U record found=false, reservation held=false
|
||||
step3 U's unpressed confirmation SURVIVED the self-heal
|
||||
step5 V pack found=true {Slug:mypack Name:mypack_by_testbot ... OwnerID:4242 Pending:false}
|
||||
step6 methods = [deleteStickerSet editMessageReplyMarkup sendMessage answerCallbackQuery]
|
||||
HOLE CONFIRMED: U deleted "mypack_by_testbot" — V's pack, created after U's record was gone
|
||||
step6 V record still found=true (local record survives, set does not)
|
||||
step6 answer = "Pack deleted."
|
||||
```
|
||||
|
||||
V is left with a committed record pointing at a destroyed set, and U is told the
|
||||
delete succeeded.
|
||||
|
||||
**Reachability: production, not hypothetical.** Dispatch is serial
|
||||
(`internal/telegram/client.go:28` `WithNotAsyncHandlers`, single worker) — this is
|
||||
a plain sequential command sequence, no race. Steps 1-3 are fully under the
|
||||
attacker's control and take seconds; the only constraint is that step 4 lands
|
||||
inside the 10-minute `pendingDeleteTTL`. The same sequence also occurs with no
|
||||
attacker at all: run `/delpack`, get distracted, empty the pack, run one more
|
||||
command, and whoever takes the freed name loses it when you finally press.
|
||||
|
||||
Second, milder variant, also executed
|
||||
(`TestProbe_StaleConfirmationAfterRecordMovedOn`): with the record moved to a
|
||||
different pack, the press still issues
|
||||
`deleteStickerSet name="mypack_by_testbot"` while the record names
|
||||
`other_by_testbot`. `dropPackRecordIfSet` correctly leaves the record alone —
|
||||
after the set is already gone.
|
||||
|
||||
### Fix shape
|
||||
|
||||
Invert the guard to a positive authorisation, matching `dropPackRecordIfSet`:
|
||||
|
||||
```go
|
||||
current, found, err := getPack(ctx, s.store, action.OwnerID)
|
||||
if err != nil { ...transient answer... }
|
||||
if !found || current.Pending || !ownsSet(current, action.SetName) {
|
||||
s.dropPendingDelete(ctx, key)
|
||||
clearButton(ctx, b, action.ChatID, action.MessageID)
|
||||
return answerCallback(ctx, b, query.ID, "That pack is no longer yours to delete; nothing was deleted at Telegram.")
|
||||
}
|
||||
```
|
||||
|
||||
Additionally, every `dropPackRecord` / `dropPackRecordIfSet` should clear
|
||||
`pendingDeleteKey(ownerID)`. A record that no longer exists must not leave a live
|
||||
capability behind it; the TTL is the only thing bounding it today.
|
||||
|
||||
---
|
||||
|
||||
## H1 (High) — the round-5 re-check is not pinned by any test
|
||||
|
||||
Mutation M2b: delete the entire `getPack` re-check block from
|
||||
`handleDelPackCallback` (delpack_callback.go:177-185).
|
||||
|
||||
**SURVIVED.** Full suite `ok`. Item 4 of the commit description — "a defensive
|
||||
re-check was also added in `handleDelPackCallback` under the lock" — has zero
|
||||
test coverage. This is the same defect class the memory file records: a claim
|
||||
asserted in the commit text that no test exercises. It is also the exact guard
|
||||
whose incompleteness produces C1, so the gap and the bug are the same omission.
|
||||
|
||||
---
|
||||
|
||||
## Mutation results (full)
|
||||
|
||||
Backed up to scratchpad, restored, `md5sum -c` all match, `git status --short`
|
||||
empty (verified after every mutation).
|
||||
|
||||
| # | Mutation | Result | Killing test |
|
||||
|---|----------|--------|--------------|
|
||||
| M1 | `createPack` `err == nil` → `finishNewPack` (adoption restored) | **killed** | `TestNewPack_InterruptedAttemptWithLiveSetIsRefused`, `TestNewPack_WipedStoreCannotAdoptSurvivingPack`, `TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver` |
|
||||
| M2 | remove `/delpack` pending short-circuit | **killed** | `TestDelPack_PendingRecordDeletesNothingAtTelegram` |
|
||||
| **M2b** | **remove `handleDelPackCallback`'s under-lock re-check** | **SURVIVED** | — |
|
||||
| M3 | neutralise `releaseSlug` ownership check | **killed** | `TestReleaseSlug_RefusesANameHeldBySomeoneElse` |
|
||||
| M4 | re-attach `releaseSlug`'s ownership read to the request ctx | **killed** | `TestReleaseSlug_CompletesOnACancelledContext` |
|
||||
| M5b | `resolveStaleIntent` `err == nil` branch adopts the old set (R4's second path) | **killed** | `TestNewPack_DifferentSlugDoesNotAdoptExistingSet` |
|
||||
| M6 | release the slug unconditionally on claim bail (drop the `created` guard) | **killed** | `TestNewPack_ResumedReservationNotReleasedWhenClaimBails` |
|
||||
| **M7** | **`resolveStaleIntent` missing-branch no longer releases the dead name** | **SURVIVED** | — |
|
||||
| M8 | `createPack` unknown-lookup branch drops intent + reservation | **killed** | `TestNewPack_UnknownLookupErrorAborts` |
|
||||
| M9 | `createPack` refusal keeps the intent | **killed** | `TestNewPack_InterruptedAttemptWithLiveSetIsRefused` |
|
||||
|
||||
M7 detail: `TestNewPack_DifferentSlugReplacesDeadIntent` (pack_handlers_test.go:149)
|
||||
is named for replacing a dead intent but asserts only the *new* pack. Nothing
|
||||
checks that `oldslug`'s reservation was freed, so the R1 name-burn class is
|
||||
unpinned. The code is correct today; only the regression barrier is missing.
|
||||
|
||||
The four tests added by this commit are otherwise non-vacuous: M1 kills
|
||||
`TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver`, M2 kills
|
||||
`TestDelPack_PendingRecordDeletesNothingAtTelegram`, M3/M4 kill the two
|
||||
`TestReleaseSlug_*` tests. The `ctxHonouringSlugs` wrapper is load-bearing — its
|
||||
comment ("this assertion passed whether or not the code detached until the store
|
||||
was made to honour cancellation") is accurate.
|
||||
|
||||
---
|
||||
|
||||
## What the commit did close (verified by execution, not inspection)
|
||||
|
||||
### No path to a committed record naming a foreign set
|
||||
|
||||
`Pending = false` is written in exactly one place, `finishNewPack`
|
||||
(pack_handlers.go:352), reachable only after `CreateNewStickerSet` returns nil.
|
||||
`adjustCount` and `handleRenamePack` preserve/require the flag. Enumerated and
|
||||
executed in `TestProbe_PostWipeAttackSurface`, `TestProbe_PendingToCommittedSweep`,
|
||||
`TestProbe_CommittedRecordOnlyAfterCreate`:
|
||||
|
||||
- post-wipe `/newpack victimslug` with the set live → `slugTaken`, record dropped,
|
||||
reservation released, no `createNewStickerSet`
|
||||
- inconclusive probe, then a second `/newpack` with the set live → refused, cleaned
|
||||
- resume with a pending record naming the victim's set → refused, cleaned
|
||||
- `createNewStickerSet` refused (`PACK_SHORT_NAME_OCCUPIED`) → intent and
|
||||
reservation both released
|
||||
- with a pending record naming the victim's set, all six other commands refuse:
|
||||
`/addsticker` and `/renamepack` → `noPackYet`; `/delsticker`, `/editsticker`,
|
||||
`/ordersticker`, `/setpackicon` → `notOwnedRefusal`. **Zero** Telegram
|
||||
mutations in every case.
|
||||
|
||||
### `/delpack` pending short-circuit cannot be aimed at another user
|
||||
|
||||
`getPack(ctx, s.store, senderID(msg))` and `dropPackRecord(ctx, ownerID)` are
|
||||
owner-keyed throughout, and `releaseSlug` re-verifies the holder itself
|
||||
(M3 confirms). Executed: a caller can only free a reservation they hold
|
||||
(`sticker_release_slug_refused` logged otherwise). Freeing a name that still has
|
||||
a live set behind it is possible but not exploitable — the next claimant is
|
||||
refused by `createPack`'s occupancy probe (verified: third party gets
|
||||
`slugTaken`).
|
||||
|
||||
### No wedge
|
||||
|
||||
`TestProbe_WedgeAudit`, 4 leftover states × 3 escape routes = 12 runs. Every
|
||||
state escapes via `/newpack <other-slug>`; 11 of 12 also escape via the same
|
||||
slug or `/delpack`. The one refusal (`pending record + foreign reservation`,
|
||||
same slug) is correct and has two working escapes.
|
||||
|
||||
---
|
||||
|
||||
## M (Medium) — resuming an interrupted `/newpack` silently uses the old title
|
||||
|
||||
`claimSlug`'s resume branch (pack_handlers.go:246) returns `existing`, discarding
|
||||
the freshly parsed `title`. Executed (`TestProbe_ResumeIgnoresNewTitle`): after a
|
||||
pending `mypack`/"Old" record, `/newpack mypack Brand New Title` calls
|
||||
`createNewStickerSet title="Old"` and answers `"Created Old."` — confirming a
|
||||
title the user did not type, with `/renamepack` the only fix. The same branch
|
||||
also reuses `existing.Name`, so after a BotFather rename every resume builds a
|
||||
set name with the stale `_by_<old>` suffix that Telegram will reject; the
|
||||
freshly computed `setName` is discarded.
|
||||
|
||||
## L1 — `resolveStaleIntent` leaks the old reservation permanently
|
||||
|
||||
The `err == nil` branch keeps the old reservation with no record pointing at it
|
||||
(`TestProbe_StaleIntentReservationLeak`: `reservation[oldslug] owner=42`, no pack
|
||||
record). Correct in intent — a set really occupies the name — but the entry is
|
||||
unreachable by any code path while the user holds another committed pack. Not
|
||||
weaponisable: reaching the branch requires a set to genuinely exist under the
|
||||
name, so it is not a cheap name-burn primitive.
|
||||
|
||||
## L2 — confirmed delete leaks the reservation when the record moved on
|
||||
|
||||
`TestProbe_ConfirmedDeleteLeaksReservationWhenRecordMoved`: `DeleteStickerSet`
|
||||
succeeds, `dropPackRecordIfSet` correctly declines to touch the moved record, and
|
||||
nobody releases `mypack` — a name whose set is now definitely gone stays reserved
|
||||
forever. Same code path as C1's second variant.
|
||||
|
||||
## L3 — `releaseSlug` read-then-delete is not atomic
|
||||
|
||||
`getSlugReservation` then `Delete` on the same key with no CAS. Under concurrent
|
||||
dispatch, a reservation re-claimed between the two calls would be deleted by the
|
||||
previous holder. **Not reachable today** (serial dispatch), but `state.go:83`
|
||||
documents the lock as "load-bearing rather than decorative" because of the cron
|
||||
scheduler and stats hook — worth a `PutVersioned`-style compare-and-delete or an
|
||||
explicit note that neither touches this store.
|
||||
|
||||
---
|
||||
|
||||
## Gates
|
||||
|
||||
| Gate | Result |
|
||||
|------|--------|
|
||||
| `go build ./...` | pass |
|
||||
| `go vet ./...` | pass |
|
||||
| `gofmt -l .` | clean |
|
||||
| `golangci-lint run ./...` | 0 issues |
|
||||
| `go test ./...` | pass |
|
||||
| `go test -race ./...` | pass |
|
||||
| `go test -race -count=20 ./internal/modules/sticker/...` | pass, 86.5s, no flakes |
|
||||
| workspace restored | `md5sum -c` all match, `git status --short` empty |
|
||||
|
||||
## Recommended actions
|
||||
|
||||
1. **Blocking** — fix C1: invert the `handleDelPackCallback` re-check to positive
|
||||
authorisation (`!found || Pending || !ownsSet` → refuse).
|
||||
2. **Blocking** — clear `pendingDeleteKey(ownerID)` in `dropPackRecord` and
|
||||
`dropPackRecordIfSet`, so a dropped record cannot leave a live delete
|
||||
capability behind.
|
||||
3. **Blocking** — add a test for the re-check (kill M2b) covering all three bad
|
||||
states: `!found`, `Pending`, and record-names-another-set. The end-to-end
|
||||
sequence in C1 is the right shape.
|
||||
4. High — extend `TestNewPack_DifferentSlugReplacesDeadIntent` to assert the old
|
||||
reservation was released (kill M7).
|
||||
5. Medium — carry the new title (and freshly computed set name) through the
|
||||
resume branch, or state in the reply that the original title was kept.
|
||||
6. Low — release the reservation in L2's branch; document or close L3.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Does Telegram in fact delete a sticker set when its last sticker is removed?
|
||||
The module documents this as undocumented behaviour. C1's step 3 uses it as the
|
||||
cheapest self-service way to make the set vanish, but the hole does not depend
|
||||
on it — any external deletion, or any `STICKERSET_INVALID` self-heal, reaches
|
||||
the same state.
|
||||
- Is `pendingDeleteTTL` (10 min) intended as a security bound? It is currently
|
||||
the only thing limiting C1's exploitation window, and the comment justifies it
|
||||
on irreversibility grounds rather than as an authorisation control.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Adversarial verification — sticker module, round 6
|
||||
|
||||
- Commit under review: `b7803ce` ("fix(sticker): prove authority before a confirmed pack delete")
|
||||
- Branch: `feature/sticker-pack-module`, Go 1.27, golangci-lint v2.13.1
|
||||
- Method: static enumeration + driven end-to-end probes + mutation testing.
|
||||
All source mutations were backed up, restored, and verified byte-identical
|
||||
(`git status --short` empty, md5sums match baseline).
|
||||
|
||||
## Verdict
|
||||
|
||||
**The security fix is correct.** I could not reach any of the seven
|
||||
owner-unscoped Telegram mutations with authority the caller does not hold,
|
||||
under serial dispatch or under forced concurrency. The R5 hole
|
||||
(`DeleteStickerSet` via a stale confirmation) is closed twice over, and I
|
||||
confirmed by driven probe that the allowlist alone still holds in the one
|
||||
production state where change 2 fails.
|
||||
|
||||
**The recurring pattern did repeat, one level down.** The flagship new test is
|
||||
vacuous with respect to the guard it is named for, and the two disjuncts of the
|
||||
new allowlist that the commit message itself identifies as the R5 bug are
|
||||
completely unpinned. Nothing in the suite stops this fix from regressing back
|
||||
into exactly the blocklist it replaced.
|
||||
|
||||
That is a test-integrity defect, not a live exploit. See "Merge position".
|
||||
|
||||
---
|
||||
|
||||
## 1. Enumeration of every owner-unscoped Telegram mutation
|
||||
|
||||
`grep` over `internal/modules/sticker/*.go` (non-test) yields exactly these
|
||||
mutating calls. For each: what proves ownership at the moment of the call, and
|
||||
whether that proof can go stale or be manufactured.
|
||||
|
||||
| Call | Site | Proof of authority at call time | Can it go stale / be forged? |
|
||||
|---|---|---|---|
|
||||
| `DeleteStickerSet` | `delpack_callback.go:210` | Under `lockUser`, immediately before the call: `found && !current.Pending && ownsSet(current, action.SetName)` re-read from the store | **No.** Non-`Pending` records are written only by `finishNewPack` (after a successful `CreateNewStickerSet`) and by `commitPack` from `adjustCount`/`handleRenamePack`, both of which copy an existing record's `Name`. So a non-`Pending` record proves this owner created that set. Gap between check and call is one store `Delete` on the pending key, no dispatch point. |
|
||||
| `CreateNewStickerSet` | `pack_handlers.go:370` | `GetStickerSet(pack.Name)` must positively return `STICKERSET_INVALID` in the same handler | No adoption path remains; `err == nil` (occupied) drops the intent and releases the reservation. Verified by probe C below. |
|
||||
| `SetStickerSetTitle` | `pack_handlers.go:591` | `getPack(ownerID)` found and `!Pending`; `Name` taken from that record | Owner-keyed read, same handler. Read happens *before* `lockUser` — hypothetical-concurrency only (see L2). |
|
||||
| `SetStickerSetThumbnail` | `setpackicon.go:44` | `resolveOwned` → `ownsSet(pack, replied.Sticker.SetName)`; `Name` from the caller's own record | Slow media leg sits between check and call, but no dispatch point under serial dispatch. |
|
||||
| `AddStickerToSet` | `sticker_handlers.go:69` | `getPack(ownerID)` found and `!Pending`; `UserID` is always the caller, `Name` from the caller's record | Same shape. |
|
||||
| `DeleteStickerFromSet` | `sticker_handlers.go:122` | `resolveOwned` → `ownsSet(pack, st.SetName)` on the replied sticker | `st.SetName` and `st.FileID` come from the same Telegram-rendered `Sticker`; not client-forgeable. Old scrollback stickers from a deleted-then-reclaimed pack are stopped because the record is dropped alongside the set. |
|
||||
| `SetStickerEmojiList` | `sticker_handlers.go:175` | `resolveOwned` | Same. |
|
||||
| `SetStickerPositionInSet` | `sticker_handlers.go:214` | `resolveOwned` | Same. |
|
||||
|
||||
**No deferred capability exists anywhere except `PendingDelete`.** Every other
|
||||
command resolves authority and consumes it inside the same handler invocation,
|
||||
so the stale-authority shape found at `/delpack` has no sibling at
|
||||
`/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`
|
||||
or `/renamepack`. I drove `/addsticker` and `/delsticker` end to end against a
|
||||
record that had moved on; both refuse at `resolveOwned`/`getPack`.
|
||||
|
||||
## 2. Attacks driven end to end (probe results)
|
||||
|
||||
Probes were written as a temporary test file, run, and removed.
|
||||
|
||||
| Probe | Setup | Result |
|
||||
|---|---|---|
|
||||
| **A** — record gone, confirmation alive | non-`Pending` pack + live `PendingDelete`, record deleted out from under it, victim seeded holding `mypack_by_testbot` | `methods = [editMessageReplyMarkup answerCallbackQuery]`. **0 `deleteStickerSet`.** Victim record intact. Allowlist holds. |
|
||||
| **A2** — record moved to `Pending`, confirmation alive | same, record replaced with a `Pending` intent naming the same set | **0 `deleteStickerSet`.** |
|
||||
| **C** — `/delpack` on a `Pending` record frees a name with a live set behind it, next claimant attacks | Bob's interrupted attempt created the set; Bob `/delpack` (frees `mypack`); Alice `/newpack mypack` | Alice: `[getMe getStickerSet sendMessage]`, reply `"That pack name is taken."` No record, no adoption. Alice's follow-up `/delpack`: `"You don't have a pack yet."`, 0 API calls. **`createPack`'s occupancy probe is the wall and it holds.** |
|
||||
| **E** — `dropPackRecord` with a failing pending store | `pending.Delete` returns an error | Record deleted, reservation released, **confirmation survives**. This is the state that makes the allowlist's `!found` disjunct load-bearing in production. |
|
||||
| **G** — can a `Pending` record coexist with a live confirmation via handlers alone? | `/delpack` (prompt live) then `/newpack second Two` | Refused at the precheck: `"You already have a pack (mypack)."` Not reachable through handlers — but *is* reachable after an E-style failed clear. |
|
||||
| **Concurrency** — 3 goroutines (`handleDelPackCallback` + `handleDelPack` + `handleAddSticker`) × 50 iterations × 3 runs, `-race` | | No races, never more than one `deleteStickerSet`. |
|
||||
|
||||
Dispatch model re-confirmed serial: `internal/telegram/client.go:27-28`
|
||||
(`WithSkipGetMe`, `WithNotAsyncHandlers`), no `WithWorkers` anywhere, so the
|
||||
library default of one worker applies. All concurrency observations below are
|
||||
labelled hypothetical.
|
||||
|
||||
## 3. Mutation testing
|
||||
|
||||
Backup → mutate → `go test ./internal/modules/sticker/` → restore.
|
||||
|
||||
| # | Mutation | Outcome | Killing test |
|
||||
|---|---|---|---|
|
||||
| 1 | Allowlist reverted to the R5 blocklist (`found && current.Pending && ownsSet(...)`) | **KILLED** | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` (`delpack_callback_test.go:286`) — *only* this one |
|
||||
| 2 | `dropPendingDelete` removed from `dropPackRecord` | **KILLED** | `TestDropPackRecord_ClearsAnOutstandingConfirmation` (`pack_handlers_test.go:874`) — *only* this one |
|
||||
| 3 | Resume returns `existing` verbatim (both carry-overs removed) | **KILLED** | `TestNewPack_ResumeUsesTheTitleJustTyped` |
|
||||
| 3b | Only `resumed.Name = intent.Name` removed | **SURVIVED** | — |
|
||||
| 4 | `releaseSlug` removed from `resolveStaleIntent`'s `isStickerSetMissing` branch | **KILLED** | `TestNewPack_DifferentSlugReplacesDeadIntent` (`pack_handlers_test.go:171`) |
|
||||
| 5 | Allowlist disjunct `!found` dropped (`_ = found`) | **SURVIVED** | — |
|
||||
| 6 | Allowlist disjunct `current.Pending` dropped | **SURVIVED** | — |
|
||||
| 7 | Allowlist disjunct `!ownsSet(current, action.SetName)` dropped | **KILLED** | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` |
|
||||
| 8 | Mutations 1 **and** 2 together | **KILLED** | all three of `StalePressLeavesTheCurrentPackAlone`, `StalePressCannotDeleteTheNextHolder`, `DropPackRecord_ClearsAnOutstandingConfirmation` |
|
||||
|
||||
Gates: `go build ./...` OK · `go test ./... -race -count=20` OK ·
|
||||
`golangci-lint run ./...` → `0 issues.` · `gofmt -l .` → clean.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### H1 — The flagship round-6 test does not exercise the round-6 guard (High, test integrity)
|
||||
|
||||
`TestDelPackCallback_StalePressCannotDeleteTheNextHolder`
|
||||
(`internal/modules/sticker/delpack_callback_test.go:411-453`) is documented as
|
||||
the regression test for the allowlist. It is not.
|
||||
|
||||
Reproduction (mutation 1, in isolation):
|
||||
|
||||
```
|
||||
$ # revert the allowlist to the R5 blocklist, nothing else
|
||||
$ go test ./internal/modules/sticker/ -run TestDelPackCallback_StalePressCannotDeleteTheNextHolder -v
|
||||
--- PASS: TestDelPackCallback_StalePressCannotDeleteTheNextHolder (0.00s)
|
||||
```
|
||||
|
||||
Cause: at line 434 the test calls
|
||||
|
||||
```go
|
||||
// U's set vanishes at Telegram; a self-heal clears the record and the name.
|
||||
s.dropPackRecord(ctx, testUser)
|
||||
```
|
||||
|
||||
`dropPackRecord` now (change 2) deletes the `PendingDelete` as well, so the
|
||||
callback returns at `delpack_callback.go:124`
|
||||
(`pending.Get` → `storage.ErrNotFound` → `"This confirmation expired or was
|
||||
already used."`) roughly fifty lines before the allowlist at line 193. The test
|
||||
proves change 2, and only change 2 — which is already proven by
|
||||
`TestDropPackRecord_ClearsAnOutstandingConfirmation`.
|
||||
|
||||
Mutation 2 in isolation also leaves this test **passing** (the allowlist then
|
||||
catches it). Only the double revert (mutation 8) fails it. A test that requires
|
||||
both defences to be removed before it fires cannot detect either one
|
||||
regressing.
|
||||
|
||||
This is the fourth consecutive round in which a test was named for a behaviour
|
||||
a structurally earlier guard prevents it from reaching.
|
||||
|
||||
**Fix:** seed the state directly instead of routing through `dropPackRecord` —
|
||||
`s.store.Delete(ctx, packKey(testUser))` and leave the confirmation in place —
|
||||
so the press actually arrives at the under-lock re-check. Probe A above is a
|
||||
working version of that test; it passes on `HEAD` and fails under mutation 1.
|
||||
|
||||
### H2 — The `!found` disjunct is unpinned, and the state it guards is production-reachable (High)
|
||||
|
||||
Mutation 5 (`_ = found; if current.Pending || !ownsSet(current, action.SetName)`)
|
||||
survives the entire suite. That disjunct is the exact half of the R5 bug the
|
||||
commit message calls out first ("fell through on the two that mattered: no
|
||||
record at all").
|
||||
|
||||
It is not dead code. `dropPackRecord` logs and continues when the pending
|
||||
delete cannot be removed:
|
||||
|
||||
```go
|
||||
func (s *state) dropPendingDelete(ctx context.Context, key string) {
|
||||
commitCtx, cancel := commitContext(ctx)
|
||||
defer cancel()
|
||||
if err := s.pending.Delete(commitCtx, key); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
log.Error("sticker_drop_pending_delete", "err", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Probe E confirms the resulting state on `HEAD`: pack record gone, reservation
|
||||
released, confirmation still live and pressable. Probe A confirms `!found` is
|
||||
what refuses the press in that state, and that without it the press lands on
|
||||
whoever holds the name now. A single Mongo write failure is enough to enter it.
|
||||
|
||||
**Fix:** add the probe-A test (record deleted directly, confirmation left
|
||||
alive, victim seeded under the same name, assert zero `deleteStickerSet`).
|
||||
|
||||
### H3 — The `current.Pending` disjunct is unpinned (Medium-High)
|
||||
|
||||
Mutation 6 survives. Reachable in production by composing H2 with a normal
|
||||
`/newpack`: once a failed `dropPendingDelete` has left a confirmation alive
|
||||
with no record, `/newpack` passes the precheck and writes a fresh `Pending`
|
||||
intent. A `Pending` record is bookkeeping written *before* Telegram is called —
|
||||
`handleDelPack` and `TestDelPack_PendingRecordDeletesNothingAtTelegram` both
|
||||
say so explicitly — so it must never authorise a delete. Probe A2 shows the
|
||||
guard works today; nothing pins it.
|
||||
|
||||
**Fix:** add probe A2 as a test.
|
||||
|
||||
### M1 — `resumed.Name = intent.Name` is unpinned and repoints the record at a different set under a bot rename (Medium)
|
||||
|
||||
`pack_handlers.go`, `claimSlug` resume branch:
|
||||
|
||||
```go
|
||||
resumed := existing
|
||||
resumed.Title = intent.Title
|
||||
resumed.Name = intent.Name
|
||||
return resumed, false, nil
|
||||
```
|
||||
|
||||
Mutation 3b (removing only the `Name` line) survives the whole suite — the
|
||||
title carry-over is the only half the new test covers, despite the `Name` line
|
||||
being the only one of the two that changes *which set* is touched.
|
||||
|
||||
Probe B, driven end to end: seed an interrupted attempt with
|
||||
`Name = "mypack_by_oldbot"` (bot renamed in BotFather since), stub `getMe` →
|
||||
`testbot`, re-run `/newpack mypack Title`:
|
||||
|
||||
```
|
||||
probed name = "mypack_by_testbot"
|
||||
created name = "mypack_by_testbot"
|
||||
stored pack = {Slug:mypack Name:mypack_by_testbot ... Pending:false}
|
||||
```
|
||||
|
||||
Before `b7803ce` the probe targeted `mypack_by_oldbot`. If the interrupted
|
||||
attempt did create that set, the old behaviour answered `slugTaken` and cleaned
|
||||
up; the new behaviour creates a second set and orphans the first with no local
|
||||
record pointing at it and no route to reach it through the bot. The `mypack`
|
||||
reservation stays held (same slug), so no cross-user damage — this is a
|
||||
resource leak and a behaviour regression, not a security defect.
|
||||
|
||||
It also directly contradicts the invariant `ownsSet`'s own doc comment states
|
||||
(`setname.go:73-79`): "It deliberately does not re-derive the name from the
|
||||
live bot username. Renaming the bot in BotFather is supported and leaves
|
||||
existing set names untouched." The resume branch now re-derives it.
|
||||
|
||||
**Fix:** either drop the `Name` carry-over (the title fix is what the commit
|
||||
message describes; the `Name` line is unexplained scope), or keep it and add a
|
||||
test that pins the intent under a changed username. As written it is an
|
||||
unexplained, untested line inside a security-sensitive commit.
|
||||
|
||||
### L1 — `ownsSet` uses Unicode case folding on a security comparison (Low, informational)
|
||||
|
||||
`strings.EqualFold` applies simple Unicode folding, so
|
||||
`ownsSet(Pack{Name: "mypack_by_testbot"}, "mypacKk_by_testbot")` (U+212A
|
||||
KELVIN SIGN) returns **true** — verified by probe F. Not exploitable: Telegram
|
||||
constrains sticker-set short names to `[A-Za-z0-9_]`, `validateSlug` forces
|
||||
`^[a-z][a-z0-9_]{2,39}$`, and the only two inputs are a stored record name and
|
||||
a Telegram-rendered `Sticker.SetName`. Recording it because the comment
|
||||
justifies `EqualFold` on casing grounds alone and does not note the folding
|
||||
surface it brings along. `strings.ToLower` comparison would be equally correct
|
||||
and narrower.
|
||||
|
||||
### L2 — Read-modify-write outside the lock in four handlers (Low, hypothetical concurrency)
|
||||
|
||||
`state.go`'s corrected `lockUser` comment says the lock "stays because every
|
||||
mutation here is a read-modify-write, which is wrong the moment dispatch stops
|
||||
being serial." Four handlers do not honour that:
|
||||
|
||||
- `handleDelPack` — reads the pack, then writes `s.pending`, with **no lock at
|
||||
all** on the prompt path (the lock is taken only inside the `pack.Pending`
|
||||
branch).
|
||||
- `handleAddSticker`, `handleDelSticker`, `handleRenamePack` — `getPack` runs
|
||||
*before* `defer s.lockUser(ownerID)()`, so the record they act on was read
|
||||
outside the critical section.
|
||||
|
||||
Only `handleNewPack` takes the lock first. Moot under
|
||||
`WithNotAsyncHandlers` + one worker; flagged because the comment asserts a
|
||||
property the code does not have, which is precisely the class of defect change
|
||||
6 was written to fix.
|
||||
|
||||
### L3 — `internal/keylock` package doc contradicts the dispatch model (Low, out of scope)
|
||||
|
||||
`internal/keylock/keylock.go:6-8`: "The bot dispatcher runs each Telegram update
|
||||
in its own goroutine". `internal/telegram/client.go:18-22` and
|
||||
`internal/modules/dispatcher.go:124-126` both say the opposite, and change 6
|
||||
corrected `state.go` to match. Same wrong-reason-for-a-right-guard shape, one
|
||||
package over. Not this commit's responsibility; worth a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Previously closed classes — re-confirmed still closed
|
||||
|
||||
| Class | Evidence |
|
||||
|---|---|
|
||||
| Post-wipe adoption | No adoption branch remains (`createPack` has only `occupied → refuse` / `missing → create` / `unknown → abort`). `TestNewPack_WipedStoreCannotAdoptSurvivingPack` passes; mutation of the occupancy branch is out of scope but the branch is asserted on directly. |
|
||||
| Inconclusive probe then live set | `TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver` passes; the guard it defeated no longer exists (refusal is unconditional). |
|
||||
| Pending record as delete authority | `handleDelPack` refuses to prompt for a `Pending` record and drops it locally; `TestDelPack_PendingRecordDeletesNothingAtTelegram` passes; the callback's `current.Pending` disjunct is a second wall (probe A2). |
|
||||
| Name-burning DoS | Precheck ordering (record read before `reserveSlug`) intact; `TestNewPack_RefusedRunsClaimNoNames` and `TestNewPack_FreshReservationReleasedWhenClaimBails` cover it. |
|
||||
| Cross-user `releaseSlug` | Ownership verified inside the operation, not the caller; `TestReleaseSlug_RefusesANameHeldBySomeoneElse` passes. |
|
||||
|
||||
## Change 2 audit (`dropPackRecord` now writes `s.pending`)
|
||||
|
||||
Every call site passes an owner the caller already owns — no cross-user aim is
|
||||
possible:
|
||||
|
||||
| Call site | `ownerID` source |
|
||||
|---|---|
|
||||
| `handleDelPack` (pending branch) | `senderID(msg)` |
|
||||
| `handleRenamePack` (`isStickerSetMissing`) | `senderID(msg)` |
|
||||
| `handleAddSticker` / `handleDelSticker` / `handleEditSticker` / `handleOrderSticker` / `handleSetPackIcon` (`isStickerSetMissing`) | `senderID(msg)` |
|
||||
| `dropPackRecordIfSet` ← delpack callback success | `action.OwnerID`, and the callback already proved `query.From.ID == action.OwnerID` and loaded the action under the presser's own key |
|
||||
|
||||
`senderID` additionally rejects bots, anonymous group admins
|
||||
(`SenderChat != nil`) and `From.ID == 0`, so `pendingDeleteKey` can never be
|
||||
built from the shared `GroupAnonymousBot` identity. Failure of the added write
|
||||
is logged and non-fatal, leaving the record deleted and the confirmation alive
|
||||
— the H2 state, which the allowlist covers.
|
||||
|
||||
## Merge position
|
||||
|
||||
`b7803ce` is a genuine, correct security fix and I would not block it on
|
||||
correctness. What I do block on is the test claim: the commit ships a test
|
||||
named for the guard it introduces, that guard can be fully reverted with the
|
||||
test still green, and two of the guard's three load-bearing disjuncts have zero
|
||||
coverage. Given five prior rounds where a false-clean was produced by exactly
|
||||
this — a same-named test that never reaches the branch — the fix should not
|
||||
land with its own regression detector inoperative.
|
||||
|
||||
Blocking work is small and mechanical: replace `s.dropPackRecord(ctx, testUser)`
|
||||
in `StalePressCannotDeleteTheNextHolder` with a direct `s.store.Delete`, and add
|
||||
the probe-A2 variant. Both are ten-line changes and both fail on `HEAD` under
|
||||
the corresponding mutation.
|
||||
|
||||
M1 (`resumed.Name`) should be resolved before merge too — decided either way,
|
||||
but not left as an untested, undescribed line in a commit about proving
|
||||
authority.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Is `resumed.Name = intent.Name` intentional, and if so what should happen to
|
||||
a set stranded under the pre-rename name? The commit message describes only
|
||||
the title fix.
|
||||
2. Does Telegram reserve a deleted sticker set's short name? Plan note R11
|
||||
still marks this unverified, and `dropPackRecord`'s release-the-name
|
||||
behaviour is documented as a no-op if it does. Unchanged by this commit.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Sticker module — round 7 scoped verification
|
||||
|
||||
- Branch `feature/sticker-pack-module`, HEAD `5e4fb0f`, Go 1.27, golangci-lint v2.13.1.
|
||||
- Scope: only H1, H2/H3, M1 from `verify-260825-1700-sticker-round6.md`, plus a scan of `5e4fb0f` for anything new. The 16-call Telegram enumeration was NOT redone.
|
||||
- All mutations were applied to working-tree copies, then restored from backup. Final state: `git status --short` empty, 27/27 md5sums match, `git diff HEAD --stat` empty.
|
||||
|
||||
## Verdict
|
||||
|
||||
**SAFE_TO_MERGE.** H1, H2/H3 and M1 are closed. The `!found` equivalent-mutant claim is correct and is itself test-pinned. Nothing unintended was found in the commit.
|
||||
|
||||
## Mutation results
|
||||
|
||||
Guard under test, `internal/modules/sticker/delpack_callback.go:197`:
|
||||
|
||||
```go
|
||||
if !found || current.Pending || !ownsSet(current, action.SetName) {
|
||||
```
|
||||
|
||||
| # | Mutation | Result | Killing test |
|
||||
|---|---|---|---|
|
||||
| M1 | drop `!found` (`_ = found` added to compile) | **SURVIVED** | none — equivalent mutant, see below |
|
||||
| M2 | drop `current.Pending` | **KILLED** | `TestDelPackCallback_StaleAuthorityNeverReachesTelegram/record_is_unconfirmed` |
|
||||
| M3 | drop `!ownsSet(...)` | **KILLED** | `.../record_moved_on` **and** `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` |
|
||||
| M4 | full revert to the round-5 blocklist (`if found && current.Pending && ownsSet(...)`) | **KILLED** | `.../record_gone`, `.../record_moved_on`, `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` |
|
||||
| M5 | re-add `resumed.Name = intent.Name` (`pack_handlers.go`) | **KILLED** | `TestNewPack_ResumeKeepsTheStoredSetName` (`set name = "mypack_by_testbot", want the stored "mypack_by_oldbot"`) |
|
||||
| M6 | drop `resumed.Title = intent.Title` (control, prior round's fix) | **KILLED** | `TestNewPack_ResumeUsesTheTitleJustTyped` (3 assertions) |
|
||||
| M7 | drop `s.dropPendingDelete(ctx, key)` from the guard body | **SURVIVED** | none — cleanup, not authority; see Informational |
|
||||
|
||||
### H1 — closed
|
||||
|
||||
The replacement test is not vacuous. M4 (full guard revert) fails two of the three table
|
||||
cases; every case therefore executes past `pending.Get` and reaches the under-lock
|
||||
allowlist, which is exactly what the round-6 test did not do. Non-vacuity is further
|
||||
proven by the *specificity* of M2 and M3: `record_is_unconfirmed` fails only when
|
||||
`current.Pending` is removed, which means `ownsSet` returned **true** there — so the
|
||||
record was really loaded and really matched, and the case is testing the Pending disjunct
|
||||
and nothing else. Same argument for `record_moved_on` and `!ownsSet`.
|
||||
|
||||
### H2/H3 — closed to the extent it can be
|
||||
|
||||
`current.Pending` is now individually killed (M2), and `!ownsSet` by two tests (M3).
|
||||
`!found` survives (M1) — correctly, as an equivalent mutant.
|
||||
|
||||
### M1 (`resumed.Name`) — closed
|
||||
|
||||
M5 is killed with a specific message. The control M6 confirms the sibling title
|
||||
assertion was not weakened while the file was edited.
|
||||
|
||||
## The `!found` equivalent-mutant claim — CONFIRMED
|
||||
|
||||
Independently verified three ways, not just by the surviving mutation:
|
||||
|
||||
1. **Source.** `getPack` (`internal/modules/sticker/pack.go:89-98`) returns a literal
|
||||
`Pack{}` on *both* non-found paths (`ErrNotFound` and error), and the error path
|
||||
returns early at the call site. So at the guard, `found == false` implies
|
||||
`current == Pack{}` implies `current.Name == ""`.
|
||||
2. **`ownsSet`.** `internal/modules/sticker/setname.go:80-82` returns false when
|
||||
`pack.Name == ""`. Hence `!ownsSet(current, _)` is already true whenever `!found`,
|
||||
and `current.Pending` is false, so the mutated expression is bit-for-bit identical
|
||||
on every reachable input.
|
||||
3. **The equivalence is itself pinned.** `internal/modules/sticker/setname_test.go:79`
|
||||
asserts `ownsSet(Pack{}, "anything") == false`. This is the property the redundancy
|
||||
depends on, so a future edit to `ownsSet` that breaks the equivalence — making
|
||||
`!found` load-bearing and silently untested — fails a test rather than passing
|
||||
quietly. This is the one thing that made the claim safe to accept rather than merely
|
||||
plausible.
|
||||
|
||||
Searched for a counterexample and found none:
|
||||
|
||||
- **Can a missing record yield a non-empty `current.Name`?** No. Both miss paths in
|
||||
`getPack` discard the decoded value and return the zero `Pack`.
|
||||
- **Can `action.SetName` be empty?** It is written once, at
|
||||
`delpack_callback.go:65` (`SetName: pack.Name`), from a record that is already proven
|
||||
`found && !pack.Pending`. Even if a corrupt store record carried `Name == ""`, `ownsSet`
|
||||
returns false for an empty `setName` too, so the guard refuses. Fail-closed either way,
|
||||
and `DeleteStickerSet` is never reached with an empty name.
|
||||
|
||||
Keeping `!found` with the comment is the right call: it costs nothing and the alternative
|
||||
is a guard whose correctness silently depends on a helper's empty-string branch.
|
||||
|
||||
## Correctness of the reverted `resumed.Name` (not just coverage)
|
||||
|
||||
The divergence only exists after a BotFather rename: `makeSetName` is deterministic in
|
||||
`(slug, username)` and the branch requires `existing.Slug == slug`, so `intent.Name !=
|
||||
existing.Name` is *only* possible when the bot's username changed between the interrupted
|
||||
attempt and the retry. Both sub-cases were driven with a probe test:
|
||||
|
||||
**(a) The interrupted attempt did create the set.** `createPack` probes
|
||||
`GetStickerSet(existing.Name)`, finds it, drops the intent, releases the slug and answers
|
||||
"name taken". The old set is stranded but the user is told. Refreshing the name instead
|
||||
would have probed the *new* name, found it free, and created a second set — orphaning the
|
||||
first one silently. The revert is the better behaviour here.
|
||||
|
||||
**(b) The interrupted attempt never created the set.** Probe output — the resume path
|
||||
really does send the stale name to Telegram:
|
||||
|
||||
```
|
||||
PROBE getStickerSet name="mypack_by_oldbot"
|
||||
PROBE createNewStickerSet name="mypack_by_oldbot"
|
||||
```
|
||||
|
||||
Real Telegram refuses a create whose short name does not end in `_by_<current username>`.
|
||||
This is the one place the revert costs something, so I drove it rather than assuming.
|
||||
Injecting Telegram's actual refusal:
|
||||
|
||||
```
|
||||
PROBE after refusal: found=false pack={} # intent dropped
|
||||
PROBE reservation still held: false # slug released
|
||||
PROBE reply="Telegram rejected that pack name. Use lowercase letters, digits and single underscores."
|
||||
PROBE attempt2 createNewStickerSet name="mypack_by_testbot"
|
||||
PROBE attempt2 stored = {Slug:mypack Name:mypack_by_testbot ... Pending:false}
|
||||
```
|
||||
|
||||
`createRefused` (`errors.go:112-123`) already matches `PACK_SHORT_NAME_INVALID` /
|
||||
`"invalid sticker set name"`, so the refusal is classified as proof-nothing-was-created,
|
||||
the stale intent and reservation are torn down, and the very next `/newpack` succeeds under
|
||||
the current username. **There is no permanent wedge** — the cost is one misleading error
|
||||
message in a rename-plus-interrupted-attempt window. That is strictly cheaper than the
|
||||
silent orphan in (a), so the revert is correct, not merely test-pinned.
|
||||
|
||||
Answering the two specific questions asked:
|
||||
|
||||
- *Stored `Name` never validated?* Every write of `Pack.Name` in production goes through
|
||||
`makeSetName`, which errors on an empty username and enforces `maxSetNameLen`. A record
|
||||
with an unvalidated or empty `Name` cannot be produced by this module, and if one
|
||||
existed, both `ownsSet` and the create probe fail closed.
|
||||
- *Stored `Name` belonging to a different slug?* Unreachable in this branch, which is
|
||||
gated on `existing.Slug == slug`; a differing slug routes to `resolveStaleIntent`, which
|
||||
re-reads the reservation before touching anything under the old name.
|
||||
|
||||
## Scan of `5e4fb0f` for anything new or unintended
|
||||
|
||||
Five files: two production (comment-only + one deleted line), two test, one report.
|
||||
|
||||
- `delpack_callback.go`: **comment only**. No behaviour change.
|
||||
- `pack_handlers.go`: one line deleted, replaced by a comment. Verified by M5/M6 that the
|
||||
surviving `resumed.Title` assignment is unchanged and still pinned.
|
||||
- No new production code, no new helper, no new abstraction, no `any` widening, no lint
|
||||
suppression, no error swallowed.
|
||||
- The replaced test dropped its seeding of the victim's *slug reservation*. That seeding
|
||||
was never asserted on in the old test either, so no assertion was weakened — the victim's
|
||||
pack record check is retained in every table case.
|
||||
- No phantom tests: every new test is mutation-killed (M4, M5) except by design.
|
||||
- No scope drift; nothing outside `internal/modules/sticker/` and `plans/reports/`.
|
||||
|
||||
## Gates
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `go vet ./...` | clean |
|
||||
| `go test ./...` | all pass |
|
||||
| `go test -race ./...` | all pass |
|
||||
| `go test -race -count=20 ./internal/modules/sticker/` | ok, 87.3s, no races, no flakes |
|
||||
| `golangci-lint run ./...` | `0 issues.` |
|
||||
| `gofmt -l .` | empty |
|
||||
|
||||
## Informational (non-blocking)
|
||||
|
||||
1. **M7 survivor.** Nothing pins that the guard clears the stale `PendingDelete` before
|
||||
refusing. Removing `s.dropPendingDelete(ctx, key)` from the guard body leaves the whole
|
||||
suite green. This is *not* an authority hole — the guard still refuses on every
|
||||
subsequent press, and the confirmation expires on its own — so it is cleanup hygiene, not
|
||||
safety. Worth one assertion in the table (`pending.Get` returns `ErrNotFound` after the
|
||||
refusal) if a future round touches this file; not worth blocking on.
|
||||
2. **Misleading refusal text after a bot rename.** In case (b) above the user is told their
|
||||
*pack name* is invalid when the real cause is that the bot was renamed. Cosmetic, rare,
|
||||
self-healing on retry.
|
||||
3. The `break_` field name in the table trips no linter under the project's config
|
||||
(`0 issues.`), so it is left alone.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,227 @@
|
||||
# Adversarial verification — sticker packs, round 4
|
||||
|
||||
Scope: round-4 fixes on `feature/sticker-pack-module` (whole module lives in one
|
||||
commit `d831747`, so round-3→4 cannot be isolated by git; current state reviewed).
|
||||
Method: source read + four constructed attacks executed against the real handlers
|
||||
in a throwaway copy of the repo, plus a 45s fuzz of the emoji splitter and an
|
||||
image-pipeline probe. `go vet`, `go test ./...` (25 pkgs), `golangci-lint run` all
|
||||
clean on the branch as committed. No repo file was modified.
|
||||
|
||||
Verdict: **DO_NOT_MERGE** — change 1 does not close the takeover it was written for.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL 1 — cross-user pack takeover still reachable; `freshReservation` is a per-invocation fact, not durable proof
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:352` (guard), `:365-374` (the branch that
|
||||
defeats it), `:289-305` (a second adopt branch that never consults the guard).
|
||||
|
||||
The guard's premise (`:329`, `:340-351`) is "a genuine interrupted attempt always
|
||||
re-enters having found its own reservation, never having made one". True. The
|
||||
converse it relies on — "an attacker naming a foreign set can only ever be the one
|
||||
who made the reservation" — is false, because the module deliberately **keeps** a
|
||||
fresh reservation whenever the lookup is inconclusive:
|
||||
|
||||
```go
|
||||
default:
|
||||
// Unknown. Keep both the intent and the reservation: the set may exist,
|
||||
// and re-running is how the user recovers.
|
||||
log.Error("sticker_newpack_lookup", "err", err)
|
||||
```
|
||||
|
||||
One inconclusive `GetStickerSet` converts the attacker's fresh reservation into a
|
||||
resumed one. The next invocation has `freshReservation == false` and adopts.
|
||||
|
||||
**Precondition (all routes):** the victim's slug reservation is absent while the set
|
||||
lives at Telegram. This is the exact state `docs/sticker-packs.md:122-127` documents
|
||||
("what a restart does when no database is configured") and promises is safe:
|
||||
"`/newpack` reports the name as taken rather than adopting a set it can no longer
|
||||
prove is yours." With `KV_PROVIDER` auto-detect (`cmd/server/main.go:259-279`), any
|
||||
deploy without `MONGO_URL` re-enters this state on **every restart**.
|
||||
|
||||
**Route A — two ordinary commands, no crash, no store error** (executed, reproduced):
|
||||
|
||||
1. Attacker sends `/newpack <victimslug> X`. `getStickerSet` answers 429 / 5xx /
|
||||
deadline-exceeded — not `STICKERSET_INVALID` — so the `default` branch keeps the
|
||||
attacker's intent *and* reservation. User sees "Something went wrong."
|
||||
2. Attacker sends the identical command again. `reserveSlug` conflicts, holder is the
|
||||
attacker → `created=false` → `createOrAdopt(..., freshReservation=false)` → set
|
||||
exists → **adopted**.
|
||||
|
||||
Observed reply: `Finished an earlier attempt at Mine. https://t.me/addstickers/mypack_by_testbot`,
|
||||
record `{Slug:mypack Name:mypack_by_testbot OwnerID:999 Pending:false Count:1}`.
|
||||
The attacker now holds `/delpack` (irreversible `DeleteStickerSet`), `/renamepack`,
|
||||
`/setpackicon`, `/delsticker` over the victim's set — all keyed by set name with no
|
||||
owner scoping.
|
||||
|
||||
Step 1 is attacker-inducible, not luck: Telegram 429s are per-bot and any user can
|
||||
provoke them, and after changes 3+4 the tail budget left for `GetStickerSet` is
|
||||
~3 s (`FetchContext` reserve), so a slow media leg alone produces
|
||||
`context deadline exceeded` → same `default` branch.
|
||||
|
||||
**Route B — `resolveStaleIntent` never checks the guard at all** (executed, reproduced).
|
||||
With intent + reservation for `<victimslug>` surviving (e.g. a crash at the refusal
|
||||
point, before `dropIntent`), the attacker runs `/newpack <anyothername>`;
|
||||
`claimSlug` → `resolveStaleIntent` re-proves only *who holds* the old reservation,
|
||||
finds the victim's set, and adopts at `:292-304`. Reply: "You already have a pack
|
||||
(mypack) from an earlier attempt — it has been restored."
|
||||
|
||||
**Route C — partial refusal cleanup** (executed, reproduced). `:359-361` is two
|
||||
independent, error-swallowing writes. If `releaseSlug` fails or the process dies
|
||||
between them, the attacker keeps the reservation and the next attempt adopts (Route A
|
||||
step 2 without needing step 1).
|
||||
|
||||
**Not exploitable (checked):** `dropIntent` is always keyed to `pack.OwnerID`, which
|
||||
is always the caller (`claimSlug` builds the intent, or `getPack(caller)` returns it);
|
||||
`releaseSlug` re-verifies the holder at `:204`. **User A cannot drop user B's intent
|
||||
or reservation.** That part of round 4 holds.
|
||||
|
||||
**Fix (prototyped and verified).** Replace the per-invocation inference with durable
|
||||
positive evidence: a `Probed bool` on `SlugReservation`, set only when
|
||||
`GetStickerSet` positively answers `STICKERSET_INVALID` for a reservation this owner
|
||||
holds, written *before* `CreateNewStickerSet`, and required by **both** adopt
|
||||
branches. Fails closed: if the flag write fails, abort before creating.
|
||||
|
||||
```go
|
||||
// createOrAdopt, err == nil branch
|
||||
if freshReservation || !s.probedClear(ctx, pack.OwnerID, pack.Slug) { ...refuse... }
|
||||
|
||||
// createOrAdopt, isStickerSetMissing branch, before CreateNewStickerSet
|
||||
if !s.markProbed(ctx, pack.OwnerID, pack.Slug) { return reply(ctx, b, msg, genericFailure) }
|
||||
|
||||
// resolveStaleIntent
|
||||
case err == nil && !held.Probed: // refuse: release old reservation, take the new intent
|
||||
```
|
||||
|
||||
With this applied, all four attack routes refuse and the entire existing suite stays
|
||||
green — one fixture needs updating (`seedInterrupted` in `pack_handlers_test.go:394`
|
||||
must seed `Probed: true`, since it models a post-probe interrupted attempt).
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM 2 — the slug-ownership check still runs *after* the media pipeline
|
||||
|
||||
`pack_handlers.go:94` (`resolveSource`) precedes `:109` (`reserveSlug`).
|
||||
|
||||
Change 3's comment (`:81-84`) says making a user who cannot create a pack pay for the
|
||||
full media pipeline "was free work for anyone who wanted to spend the bot's CPU" —
|
||||
but that is still exactly what happens when the slug belongs to someone else.
|
||||
Executed: `/newpack <slug-held-by-another-user>` replying to a photo issued `getFile`
|
||||
and the file download before any slug check; on a decodable image it also resamples
|
||||
and `UploadStickerFile`s. Methods recorded: `[getFile x.jpg sendMessage]`.
|
||||
|
||||
Impact, single dispatch worker (`WithNotAsyncHandlers`, one worker): each such message
|
||||
occupies the bot for the whole leg, and the attacker never acquires a pack so the new
|
||||
`Pending` precheck never starts refusing them — the loop is unbounded. Measured
|
||||
`toStickerPNG` cost on the single worker: 0.71 s for a flat 4096×4096 source (360 KB
|
||||
on the wire) and 3.7 s for a noisy one (the ladder rungs). `mediaContext` does not
|
||||
bound this — `toStickerPNG` takes no context and cannot be interrupted. Wasted
|
||||
`UploadStickerFile` calls also burn the bot's API quota, which is the 429 that
|
||||
Finding 1 step 1 needs.
|
||||
|
||||
Fix: a read-only `getSlugReservation` before `resolveSource` — refuse when held by
|
||||
another user. Read-only, so it does not reintroduce the round-1 name-burning DoS
|
||||
(the create-only `reserveSlug` write stays where it is).
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM 3 — detached commit contexts are not actually protected at shutdown
|
||||
|
||||
`state.go:53-61`, `cmd/server/main.go:220-227`, `:125`.
|
||||
|
||||
Change 2 extends `context.WithoutCancel` to the reads, on the stated grounds that "a
|
||||
commit that records a completed Telegram-side action must not be lost because the
|
||||
process is shutting down". `main` does not honour that: on SIGTERM it cancels
|
||||
`rootCtx`, calls `srv.Shutdown` on the health server (returns in ms with no live
|
||||
connections), then returns — running `defer closeProvider()`, which disconnects Mongo.
|
||||
Nothing waits for the in-flight inline handler. The detached context survives
|
||||
cancellation but the process does not wait for the write, so a deploy can still lose
|
||||
the commit that these comments promise is safe.
|
||||
|
||||
Fix: track in-flight dispatch with a `sync.WaitGroup` (or a drain deadline) before
|
||||
`closeProvider`, or downgrade the comments to "best effort".
|
||||
|
||||
---
|
||||
|
||||
## LOW 4 — `downloadTimeout` is now dead
|
||||
|
||||
`download.go:25,39` vs `state.go:78-80`. `FetchContext` reserves 3 s of a 10 s
|
||||
handler, so `mediaCtx` is ≤ ~7 s and the 8 s `http.Client.Timeout` can never bind.
|
||||
The comment still calls it "this module's own ceiling". Either lower it to match the
|
||||
real budget or say it is a backstop for a caller with no deadline.
|
||||
|
||||
## LOW 5 — `lockUser`'s stated rationale is not true for this module
|
||||
|
||||
`state.go:82-84` claims "the cron scheduler and the detached per-command stats hook
|
||||
run concurrently with them, so this is load-bearing". Grepped: `keylock` here is
|
||||
`state`-local, the sticker module registers no cron jobs (`sticker.go:21-88`), and the
|
||||
stats hook (`dispatcher.go:82-90`) touches only the stats collection. Nothing else
|
||||
acquires these keys, and all sticker handlers run inline on the single dispatch
|
||||
goroutine. Keep the lock (cheap, correct if dispatch ever goes async) but fix the
|
||||
claim. Corollary: change 3 cannot deadlock or block the worker — verified, see below.
|
||||
|
||||
## LOW 6 — nested `commitContext` re-arms the budget
|
||||
|
||||
`pack_handlers.go:483` passes an already-detached ctx into `dropPackRecord:520`, which
|
||||
derives another. `context.WithoutCancel` drops the parent deadline, so the inner
|
||||
helper gets a fresh 5 s, and `dropPackRecord` → `releaseSlug` adds a third. A
|
||||
`/delpack` confirm can therefore spend ~15 s in detached cleanup. No leak (every
|
||||
`cancel` is deferred) and every op is bounded; just not the 5 s the constant implies.
|
||||
|
||||
## LOW 7 — recording bot truncates instead of rejecting oversized bodies
|
||||
|
||||
`testutil/recording_bot.go:195` reads through `io.LimitReader(r.Body, 8<<20)`; a body
|
||||
above the cap is silently truncated and then fails `ParseMultipartForm` as a confusing
|
||||
"bad multipart form: unexpected EOF" rather than a size error. No current test is near
|
||||
the cap. Reading one extra byte and reporting "body too large" would match the
|
||||
module's own `downloadFile:75` pattern.
|
||||
|
||||
## LOW 8 — duplicated refusal text
|
||||
|
||||
`pack_handlers.go:89-91` and `:248-250` build the same "You already have a pack" reply
|
||||
independently. One helper; they will drift.
|
||||
|
||||
---
|
||||
|
||||
## Attacked and held
|
||||
|
||||
- **Cross-user destruction of state.** `releaseSlug` re-reads and compares the holder
|
||||
(`:204`) and `dropIntent` is always keyed by the caller's own id. Probed both adopt
|
||||
refusal paths: user A cannot drop user B's intent or reservation. Held.
|
||||
- **Deadlock / worker starvation from change 3.** `WithNotAsyncHandlers` +
|
||||
one worker (`internal/telegram/client.go:26-30`) means all sticker handlers run
|
||||
serially on one goroutine; the `keylock.Map` is `state`-local; no cron, no hook, no
|
||||
detached goroutine touches it; no handler nests a second `lockUser`. Held (the lock
|
||||
is uncontended today).
|
||||
- **`Pending` record behaviour after moving the precheck.** The precheck refuses only
|
||||
`found && !existing.Pending`, so a pending user still falls through to the same
|
||||
`claimSlug` resume path as before. No behaviour change. Held.
|
||||
- **Change 5, extreme aspect ratios.** Executed 4096×1, 1×4096, 4096×3, 1×1,
|
||||
4096×4096: outputs 512×1, 1×512, 512×1, 512×512, 512×512 — no zero dimension, long
|
||||
edge exactly 512, and the ladder's targets still derive from the *original* bounds
|
||||
so the aspect is identical to the one-step version. Thumbnail path stays 100×100.
|
||||
Held.
|
||||
- **Change 6, emoji clustering.** 45 s / 526k-exec fuzz over valid UTF-8: no panic, no
|
||||
infinite loop, no empty cluster, and nothing dropped except leading/trailing ZWJ and
|
||||
whitespace. Spot-checked the singleton table against the standard non-block emoji
|
||||
set (©, ®, ‼, ⁉, ™, ℹ, Ⓜ, ⤴, ⤵, 〰, 〽, ㊗, ㊙) — complete. Held.
|
||||
- **Change 7, other modules' tests.** 28 files reference `NewRecordingBot`; full
|
||||
`go test ./...` green. The library always sends `multipart/form-data` with a
|
||||
boundary header and only omits the body for nil-param methods
|
||||
(`raw_request.go:29-72`), so "empty body ⇒ skip parse, non-empty ⇒ must parse" is
|
||||
the correct split, and replacing `r.Body` after buffering works because the boundary
|
||||
comes from the unchanged header. Held.
|
||||
- **`/delpack` callback authorisation.** Lookup keyed by presser, owner compared,
|
||||
chat+message binding before any side effect, action consumed before the destructive
|
||||
call. Held.
|
||||
- **Token leakage.** `classify` inspects error *types* only; `errDownloadFailed`
|
||||
replaces every download error; `replyErr` echoes only `userError`. Held.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Is production on Mongo or on the auto-detected memory backend? Finding 1 is routine
|
||||
after any restart on memory, and needs data loss on Mongo. It should be fixed either
|
||||
way, but this decides whether it blocks the deploy or only the config.
|
||||
2. `docs/sticker-packs.md:122-127` states the wiped-store refusal as a guarantee. It is
|
||||
currently only true for the first attempt; the doc needs no change once Finding 1 is
|
||||
fixed, but it should not ship as-is.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Round-4 Verification — sticker module (adversarial, mutation-driven)
|
||||
|
||||
Date: 2026-08-25 · Branch `feature/sticker-pack-module` @ `30fa3b3` (identical to
|
||||
`origin/feature/sticker-pack-module`) · Go 1.27.0 linux/arm64 · golangci-lint v2.13.1
|
||||
|
||||
Method: every claim tested by mutating source and re-running the package, not by
|
||||
reading. 34 mutations applied and reverted. All files restored byte-identical
|
||||
(md5 verified, `git status --short` empty).
|
||||
|
||||
## Verdict
|
||||
|
||||
**All six round-4 claims verified.** No claim refuted. But three *previously
|
||||
claimed* guarantees are unpinned and one test name is still misleading (the same
|
||||
class of defect as round 3's `...SurvivesABail`). None of this is a new production
|
||||
defect; it is test-coverage overstatement.
|
||||
|
||||
## 1. Mutation results
|
||||
|
||||
### Round-4 claims under test
|
||||
|
||||
| # | Mutation | Result | Killing test |
|
||||
|---|---|---|---|
|
||||
| M1a | `handleNewPack`: delete `if created { s.releaseSlug(...) }` | **KILLED** | `TestNewPack_FreshReservationReleasedWhenClaimBails` |
|
||||
| M1b | `handleNewPack`: make the release unconditional | **KILLED** | `TestNewPack_ResumedReservationNotReleasedWhenClaimBails` |
|
||||
| M2 | `createOrAdopt`: remove the `if freshReservation` adoption gate | **KILLED** | `TestNewPack_WipedStoreCannotAdoptSurvivingPack` |
|
||||
| M3 | `handleAddSticker`: early `return nil` (handler is a no-op) | **KILLED** | `TestAddSticker_EmojiPrecedence` (+both subtests), `TestAddSticker_FallsBackToDefaultEmoji`, +6 others |
|
||||
| M3b | invert precedence: source emoji beats explicit args | **KILLED** | `TestAddSticker_EmojiPrecedence/explicit_wins` |
|
||||
| M3c | drop source-emoji inheritance | **KILLED** | `TestAddSticker_EmojiPrecedence/inherits_from_replied_sticker` |
|
||||
| M4 | remove `"sticker": sticker.New` + import from `factories()` | **KILLED** | `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` — and by the **reverse check specifically**: 9 lines `command_menu_test.go:133: /<cmd> is expected but not registered` |
|
||||
| M5 | `recording_bot.handle`: tolerate any multipart parse failure (`if err == nil`) | **KILLED** | `TestRecordingBot_RejectsMalformedMultipart` |
|
||||
| M6a | `isBinding`: disable tag-block (`E0020..E007F`) binding | **KILLED** | `TestParseEmoji_ClusterEdgeCases/tag_sequence_flag` |
|
||||
| M6b | `splitClusters`: ZWJ absorbs the next rune unconditionally | **KILLED** | `.../joiner_before_a_flag` |
|
||||
| M6c | `trimJoiners` → identity | **KILLED** | `.../joiner_before_a_flag`, `.../trailing_joiner` |
|
||||
| M6d | `isEmojiCluster`: accept a lone regional indicator | **KILLED** | `.../lone_regional_indicator`, `.../odd_regional_indicator_count` |
|
||||
| M6e | drop each of the 9 new `emojiSingletons` entries, one at a time | **9/9 KILLED** | `.../copyright`, `/registered`, `/arrow_curving_up`, `/arrow_curving_down`, `/circled_m`, `/wavy_dash`, `/part_alternation`, `/japanese_congratulations`, `/japanese_secret` |
|
||||
|
||||
Claims 1-6: **verified, all directions.** Claim 1's two-direction pinning is real —
|
||||
M1a and M1b are killed by *different* tests, and by only one test each.
|
||||
|
||||
### Additional adversarial mutations (not claimed, run to find gaps)
|
||||
|
||||
| # | Mutation | Result | Killing test |
|
||||
|---|---|---|---|
|
||||
| M8 | `createOrAdopt` unknown-error branch releases the slug | KILLED | `TestNewPack_UnknownLookupErrorAborts`, `TestNewPack_ResumedReservationSurvivesABail` |
|
||||
| M10 | `releaseSlug`: drop its **own** ownership check | **SURVIVED** | — |
|
||||
| M11b | `resolveStaleIntent`: skip the reservation-owner re-proof | KILLED | `TestNewPack_StaleIntentCannotAdoptForeignName` |
|
||||
| M12b | `reserveSlug`: treat another user's reservation as resumable | KILLED | `TestNewPack_CannotSeizeAnotherUsersPack`, `TestNewPack_ForeignReservationRefusedBeforeAnyAPICall` |
|
||||
| M13 | refused adoption leaves intent + reservation behind | KILLED | `TestNewPack_WipedStoreCannotAdoptSurvivingPack` |
|
||||
| M14 | `releaseSlug`: read reservation on request ctx, not `commitContext` | **SURVIVED** | — |
|
||||
| M16b | delpack callback: drop `query.From.ID != action.OwnerID` | **SURVIVED** | — |
|
||||
| M17 | delpack callback: drop the message-binding check | KILLED | `TestDelPackCallback_RejectsWrongBinding` (+subtests), `TestDelPackCallback_BystanderCannotTouchAnotherUsersPrompt` |
|
||||
| M18 | delpack callback: drop the expiry check | KILLED | `TestDelPackCallback_RejectsExpired` |
|
||||
| M19b | delpack callback: drop the `action.ID != id` nonce check | **SURVIVED** | — |
|
||||
| M6e' | drop each pre-existing singleton `203C`, `2049`, `2122`, `2139` | **4× SURVIVED** | — |
|
||||
| M7 | drop each `emojiRanges` entry, one at a time | 3 KILLED (`1F300`, `2600`, `2B00`) / **5 SURVIVED** (`1F000`, `2190`, `2300`, `25A0`, `1F1E6`) | — |
|
||||
|
||||
**Score: 29 killed / 34 mutation slots, 12 survivors across 5 distinct sites.**
|
||||
|
||||
## 2. Emoji differential probe (full rune space)
|
||||
|
||||
Temp in-package test walked `0x0..0x10FFFF` comparing `isEmojiRune` against a
|
||||
reconstructed round-3 switch (same 8 blocks + the 4 pre-existing singletons).
|
||||
`emoji.go` exists in exactly one commit (`d831747`) on this branch and nowhere in
|
||||
history/reflog/other branches, so the old switch could not be recovered verbatim
|
||||
— it was reconstructed from the range table plus the round-3 correctness report
|
||||
(`correctness-review-260825-1515-sticker-module.md:116-131`, which enumerates the
|
||||
9 refused codepoints and names `2122`/`2139` as already special-cased).
|
||||
|
||||
```
|
||||
deliberate additions observed: 9 of 9 -> [U+00A9 U+00AE U+24C2 U+2934 U+2935 U+3030 U+303D U+3297 U+3299]
|
||||
UNEXPECTED classification changes: 0
|
||||
```
|
||||
|
||||
**Result: zero unexpected classification changes.** The switch→array+map
|
||||
restructure is behaviour-preserving modulo exactly the 9 intended additions.
|
||||
|
||||
Second, non-circular probe against authoritative Unicode data
|
||||
(`unicode.org/Public/UCD/latest/ucd/emoji/emoji-data.txt`, 1288 lines):
|
||||
|
||||
```
|
||||
accepted-but-not Emoji/Extended_Pictographic: 2140 (e.g. U+2190..U+21FF arrows, U+25A0.. shapes)
|
||||
Emoji-property runes rejected: 12 -> [U+0023 U+002A U+0030..U+0039]
|
||||
```
|
||||
|
||||
The 12 rejections are the keycap bases, handled by the `keycapCombining` branch in
|
||||
`isEmojiCluster` — not a defect. The 2140 false positives are the documented
|
||||
block-approximation trade-off (`emoji.go` comment: "Deliberately ranges rather
|
||||
than a property lookup"), pre-existing and low impact: Telegram answers
|
||||
`STICKER_EMOJI_INVALID` and `apiRefusal` renders a sane message. Not a round-4
|
||||
regression. Informational only.
|
||||
|
||||
## 3. New / still-wrong tests found
|
||||
|
||||
### F-1 (MEDIUM) — `TestNewPack_ResumedReservationSurvivesABail` still does not test what its name and comment claim
|
||||
|
||||
`pack_handlers_test.go:573`. Its comment says *"A reservation the caller merely
|
||||
resumed must not be released when a later step bails — it predates this command."*
|
||||
That is the `created`-flag distinction. Proven false by mutation:
|
||||
|
||||
- under **M1b** (release made unconditional — i.e. the resumed/fresh distinction
|
||||
deleted outright) this test still passes: `ok ... 0.019s`. Only
|
||||
`TestNewPack_ResumedReservationNotReleasedWhenClaimBails` catches M1b.
|
||||
- what it *actually* pins is M8: `createOrAdopt`'s unknown-error branch must not
|
||||
release. So does `TestNewPack_UnknownLookupErrorAborts`, which M8 also killed.
|
||||
|
||||
It is a duplicate of `TestNewPack_UnknownLookupErrorAborts` wearing the name of the
|
||||
round-4 test that replaced it. Round 4 correctly added the two real tests but left
|
||||
the misnamed one in place. Rename to `TestNewPack_UnknownLookupErrorKeepsTheReservation`
|
||||
or delete it.
|
||||
|
||||
### F-2 (MEDIUM) — `TestDelPackCallback_RejectsOtherUser` does not exercise the owner check
|
||||
|
||||
`delpack_callback_test.go`. **M16b survived**: deleting
|
||||
`if query.From.ID != action.OwnerID` from `delpack_callback.go:118` breaks no test.
|
||||
Reason: the record is fetched with `key := pendingDeleteKey(query.From.ID)` (`:108`),
|
||||
so a foreign presser has no record at all and exits three lines earlier via
|
||||
`ErrNotFound` → *"This confirmation expired or was already used."* The owner check
|
||||
is structurally unreachable; the test named for it passes through a different
|
||||
branch. Authorization is genuinely enforced (by the key), so this is a test-naming
|
||||
and dead-code issue, not a hole. Fix: assert the *reply text*, or drop the
|
||||
unreachable branch.
|
||||
|
||||
### F-3 (LOW) — `releaseSlug`'s own ownership check is unpinned
|
||||
|
||||
**M10 survived.** The code comment (`pack_handlers.go:176-181`) states the check
|
||||
exists precisely because *"this module has already been bitten once by an ownership
|
||||
check that lived in the caller instead of the operation."* Nothing tests it. Every
|
||||
current call site happens to pass the correct owner, so the check is presently
|
||||
redundant — which is exactly why a future call site could silently regress it.
|
||||
|
||||
### F-4 (LOW) — round-3's `commitContext` fix in `releaseSlug` is unpinned
|
||||
|
||||
**M14 survived.** Moving the ownership read back onto the request context breaks
|
||||
nothing: there is no cancelled-context test in the package (`grep context.WithCancel
|
||||
internal/modules/sticker/*_test.go` → 0 hits). The comment at `:183-190` describes a
|
||||
concrete failure mode (SIGTERM mid-release leaves an unreachable reservation) with
|
||||
no test behind it.
|
||||
|
||||
### F-5 (LOW) — delpack nonce check unpinned
|
||||
|
||||
**M19b survived.** Acknowledged in-code as defence-in-depth subsumed by the
|
||||
message binding (`:141-143`), so lower priority than F-2, but it is untested
|
||||
dead weight.
|
||||
|
||||
### F-6 (LOW) — dead `emojiRanges` entry with a load-bearing-sounding comment
|
||||
|
||||
`{0x1F1E6, 0x1F1FF}` is fully contained in `{0x1F000, 0x1F2FF}` — **M7 confirms both
|
||||
survive individual deletion**. Its comment ("regional indicators; isEmojiCluster
|
||||
requires a pair") reads as though it is required. It is not. Same for the untested
|
||||
`2190`, `2300`, `25A0`, `1F000` entries and the four pre-existing singletons
|
||||
(`203C`, `2049`, `2122`, `2139`) — all silently deletable.
|
||||
|
||||
### No vacuous-loop assertions remain
|
||||
|
||||
Swept every `for _, call := range rb.Sent()` in the changed packages. The four hits
|
||||
are either negative assertions (correct as loops), counting helpers, or already
|
||||
gated by a preceding `countMethod(...) != 1` fatal. Round 4's `addedStickerPayload`
|
||||
helper is genuine — M3 proves it. Also scanned all 89 sticker tests + the new
|
||||
dispatcher/testutil/cmd tests for zero-assertion bodies: 4 heuristic hits, all
|
||||
false positives (the assertion is a `Fatalf` on `err == nil`).
|
||||
|
||||
## 4. Regression sweep — `internal/testutil/recording_bot.go`
|
||||
|
||||
26 test files across 18 packages construct a `RecordingBot`. All pass.
|
||||
|
||||
The behaviour change (non-empty body that fails multipart parse → 400 instead of
|
||||
200-with-empty-form) is scoped correctly:
|
||||
|
||||
- `go-telegram/bot@v1.20.0 raw_request.go:28-71` always sends multipart; when
|
||||
`params == nil` (parameterless methods) it skips both `buildRequestForm` and
|
||||
`form.Close()`, so the body is **zero bytes** — the `len(body) > 0` gate is the
|
||||
right discriminator. `TestRecordingBot_ServesParameterlessCall` confirms getMe.
|
||||
- The exact hazard the comment warns about exists in the repo:
|
||||
`internal/modules/stock/dividend_flow_test.go:115` and `:139` assert
|
||||
`Form["reply_markup"] != ""` is false. Both still pass — the change *protects*
|
||||
them rather than breaking them.
|
||||
- No caller asserts on a field only present under a file part.
|
||||
|
||||
`go test -race -count=1 ./...` — clean, no races, exit 0.
|
||||
`go test -count=20 ./internal/modules/sticker/` — `ok ... 7.964s`, no flakes.
|
||||
|
||||
Nit (non-blocking): `recording_bot.go handle()` carries two overlapping comment
|
||||
paragraphs saying the same thing — a stale round-3 paragraph left above the
|
||||
round-4 one.
|
||||
|
||||
## 5. Gates
|
||||
|
||||
```
|
||||
golangci-lint run ./... → 0 issues.
|
||||
gofmt -l . (minus third_party) → (empty)
|
||||
go test -race -count=1 ./... → clean
|
||||
```
|
||||
|
||||
## 6. Final state (verbatim)
|
||||
|
||||
```
|
||||
$ git status --short
|
||||
$
|
||||
```
|
||||
(empty)
|
||||
|
||||
md5 of every tracked file diffed against the pre-review baseline: **MD5 IDENTICAL**.
|
||||
|
||||
```
|
||||
$ go test ./...
|
||||
ok github.com/tiennm99/miti99bot/cmd/server (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/cron (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/deploynotify (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/keylock (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/log (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/metrics (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/amlich (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/coin (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/gold (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/lol (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/loldle (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/misc (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/monkeyd (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/stats (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/sticker (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/stock (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/util (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/util/chathelper (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/wordle (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/server (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/storage (cached)
|
||||
? github.com/tiennm99/miti99bot/internal/systemstate [no test files]
|
||||
ok github.com/tiennm99/miti99bot/internal/telegram (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/testutil (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/testutil/mongotest (cached)
|
||||
```
|
||||
|
||||
## 7. Recommended actions
|
||||
|
||||
1. **F-1** — rename or delete `TestNewPack_ResumedReservationSurvivesABail`. Third
|
||||
round running that a test in this file claims more than it proves; the name is
|
||||
what misled round 3.
|
||||
2. **F-2** — assert the reply text in `TestDelPackCallback_RejectsOtherUser`, or
|
||||
remove the unreachable `query.From.ID != action.OwnerID` branch.
|
||||
3. **F-3 / F-4** — add two small tests: a cross-owner `releaseSlug` call, and a
|
||||
`releaseSlug` under a cancelled parent context. Both are ~10 lines and pin
|
||||
fixes whose comments describe real past incidents.
|
||||
4. **F-6** — delete `{0x1F1E6, 0x1F1FF}` from `emojiRanges` or fix its comment.
|
||||
5. Drop the duplicated comment paragraph in `recording_bot.go handle()`.
|
||||
|
||||
None of 1-5 blocks merge. All are test/comment hygiene against real production
|
||||
behaviour that is correct today.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- The pre-round-4 `isEmojiRune` switch is unrecoverable from git (single squashed
|
||||
commit). The differential probe is therefore partly circular *for the singleton
|
||||
set* — it is fully independent for the eight ranges. If the round-3 source is
|
||||
available elsewhere, re-running the probe against the verbatim original would
|
||||
close the last gap.
|
||||
- Are the four pre-existing singletons (`203C ‼`, `2049 ⁉`, `2122 ™`, `2139 ℹ`)
|
||||
intentionally unlisted in `TestParseEmoji_ClusterEdgeCases`, or an oversight when
|
||||
round 4 added the nine?
|
||||
Reference in New Issue
Block a user