Merge pull request #4 from tiennm99/feature/sticker-pack-module

feat(sticker): sticker pack module
This commit is contained in:
2026-08-25 17:20:14 +07:00
committed by GitHub
55 changed files with 10216 additions and 24 deletions
+5 -2
View File
@@ -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).
+1
View File
@@ -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.
+27
View File
@@ -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)
+2
View File
@@ -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,
}
}
+2
View File
@@ -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>]
+158
View File
@@ -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: 340 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 120 emoji per sticker. There is no
documented file-size limit for static stickers; the module applies its own
client-side ceiling and never presents it as a Telegram rule.
## 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.
+2 -1
View File
@@ -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
)
+4 -2
View File
@@ -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=
+42 -2
View File
@@ -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
+203
View File
@@ -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)
}
})
}
}
+106
View File
@@ -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"
}
+157
View File
@@ -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)
}
}
+209
View File
@@ -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
}
+154
View File
@@ -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)
}
})
}
}
+123
View File
@@ -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",
)
}
+440
View File
@@ -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")
}
}
+162
View File
@@ -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
}
+156
View File
@@ -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)
}
}
}
+104
View File
@@ -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
}
+614
View File
@@ -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)
}
}
+71
View File
@@ -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)
}
}
+103
View File
@@ -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')
}
+86
View File
@@ -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.")
}
+86
View File
@@ -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)
}
})
}
}
+120
View File
@@ -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
}
+190
View File
@@ -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")
}
}
+39
View File
@@ -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
}
+63
View File
@@ -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)
}
}
}
+119
View File
@@ -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
}
+97
View File
@@ -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)
}
}
+56
View File
@@ -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.")
}
+127
View File
@@ -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."
+89
View File
@@ -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))
}
+8 -8
View File
@@ -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 {
+97 -9
View File
@@ -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)))
}
+105
View File
@@ -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 120; 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 (164 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 C1C11 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 25 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. C1C8 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 | 164 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 | 120 |
| Set title | 164 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 35. 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, 120 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"}, "mypack_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?