chore: remove the plans directory

Finished plans, journals and reports stay in git history.
This commit is contained in:
tiennm99 committed 2026-10-07 16:40:24 +07:00
1 parent be46d4c19c
commit cc9080c72e
47 files changed
-7044

No files matched your search

@@ -1,75 +0,0 @@
---
phase: 1
title: Lunar core helpers
status: completed
effort: S
priority: P2
dependencies: []
---
# Phase 1: Lunar core helpers
## Overview
Add the two pure helpers the handlers need: the disputed-boundary set + membership check, and
leap-month existence probing. No behavior change to existing conversion functions.
## Requirements
- Functional: `nearDisputedBoundary(jdn int) bool` reports whether the lunar month containing
solar day `jdn` starts or ends on one of the 7 disputed month boundaries.
- Leap-variant existence needs NO new helper: handlers probe `lunarToSolar(day, month, year, true)`
and treat `err == nil` as "leap variant exists" (DRY — reuses existing validation; also naturally
suppresses the hint when e.g. day 30 doesn't exist in the 29-day leap month).
- Non-functional: helpers stay in `lunar.go`, unexported, zero allocations on the hot path
(package-level set built once).
## Architecture
- `disputedMonthStarts` — package-level `map[int]bool` built in a `var` initializer from
`jdFromDate` on the 7 dates recorded in `docs/amlich-known-issues.md`:
09/12/2072, 15/11/2077, 07/05/2130, 26/05/2150, 17/05/2159, 22/01/2175, 26/01/2199.
- `nearDisputedBoundary(jdn int) bool` — mirror `solarToLunar`'s month-start search:
```go
k := floorInt((float64(jdn) - jdNewMoonEpoch) / newMoonCycle)
monthStart := getNewMoonDay(k + 1)
for monthStart > jdn {
k--
monthStart = getNewMoonDay(k + 1)
}
return disputedMonthStarts[monthStart] || disputedMonthStarts[getNewMoonDay(k+2)]
```
Rationale for checking next start too: if the *end* boundary of the containing month is disputed,
the month's length (and day numbers near its end) is what may shift.
## Related Code Files
- Modify: `internal/modules/amlich/lunar.go`
- Modify: `internal/modules/amlich/lunar_test.go`
## Implementation Steps
1. Add `disputedMonthStarts` set with a comment explaining provenance (new moon within ±2 min of
UTC+7 midnight; see docs/amlich-known-issues.md) — no plan/audit labels in comments.
2. Add `nearDisputedBoundary`.
3. Test: each of the 7 JDs is an actual month start per the engine — for each date, assert
`solarToLunar(d,m,y)` returns lunar day 1. If any assertion fails ±1 day, the doc date and the
engine's boundary disagree: adjust the set entry to the engine's month start and flag the doc
discrepancy in the phase report.
4. Test `nearDisputedBoundary`: true for a mid-month day of the month starting 09/12/2072, true for
a day in the month *before* it (whose end boundary is disputed), false for an ordinary 2072 date
far from the boundary and for a 2024 date.
## Success Criteria
- [ ] 7 set entries verified as engine month starts by test
- [ ] `nearDisputedBoundary` true/false cases covered as above
- [ ] Existing lunar tests untouched and green
## Risk Assessment
- Doc dates might be the full-Meeus engine's boundaries rather than the current engine's (off by
one day). Mitigation: step 3's pin test resolves it mechanically; the ±1-day neighborhood is the
same disputed lunation either way.
@@ -1,66 +0,0 @@
---
phase: 2
title: Handler replies
status: completed
effort: S
priority: P2
dependencies:
- 1
---
# Phase 2: Handler replies
## Overview
Wire the leap-month hint into `/duonglich` and the razor-edge caveat into both commands. Reply
sentences gain optional trailing lines only; existing first-line format is unchanged.
## Requirements
- Functional (`/duonglich` hint): after a successful conversion with `leap == false`, if
`lunarToSolar(day, month, year, true)` returns nil error, append:
`Lưu ý: năm âm lịch <year> có tháng <month> nhuận — thêm "nhuan" nếu ý bạn là tháng nhuận.`
- Functional (caveat, both commands): if `nearDisputedBoundary(jd)` — where `jd` is the *solar* JD
of the queried/resulting date — append:
`Lưu ý: ngày này gần ranh giới tháng âm lịch chưa chắc chắn; kết quả có thể lệch 1 ngày so với lịch chính thức sau này.`
- Hint NOT shown when `nhuan` was explicit (user already disambiguated) or when the exact leap date
doesn't exist. Both lines may co-occur (hint first, then caveat), each on its own line after the
main sentence, separated by `\n`.
- Non-functional: no change to usage strings, error paths, or year-range checks.
## Architecture
- `/amlich`: `jd := jdFromDate(day, month, year)` (already-parsed solar input) → caveat check.
- `/duonglich`: caveat check on `jdFromDate(solarDay, solarMonth, solarYear)` from the conversion
result; hint check via the leap-variant probe before assembling the reply.
- Message constants live next to the usage constants in `handlers.go`.
## Related Code Files
- Modify: `internal/modules/amlich/handlers.go`
- Modify: `internal/modules/amlich/handlers_test.go`
## Implementation Steps
1. Add the two message constants (hint as format string taking year + month).
2. `/duonglich`: after successful `lunarToSolar`, build reply, conditionally append hint (probe
with `leap=true` only when input `leap == false`), then conditionally append caveat.
3. `/amlich`: conditionally append caveat after the main sentence.
4. Tests (extend existing fake-reply pattern in `handlers_test.go`):
- `/duonglich 5/5/2028` → hint present (2028 has leap 5); `/duonglich 5/5/2028 nhuan` → absent;
`/duonglich 5/5/2027` → absent (no leap 5 in 2027).
- `/amlich` on a date inside the disputed month at 09/12/2072 → caveat present; ordinary date →
absent. `/duonglich` case whose result lands in that month → caveat present.
- Assert exact full reply strings (repo test style asserts exact text — keep it strict).
## Success Criteria
- [ ] Hint behavior matches the three `/duonglich` cases above
- [ ] Caveat fires for disputed-month dates in both commands, silent elsewhere
- [ ] All pre-existing handler tests pass without assertion loosening
## Risk Assessment
- Double-probe cost: one extra `lunarToSolar` call per leap-year query — microseconds, irrelevant.
- Wording is user-visible contract; if the user wants different Vietnamese phrasing, only the
constants change. Flag wording in the PR description for review.
@@ -1,75 +0,0 @@
---
phase: 3
title: Golden-table testdata and docs
status: completed
effort: S
priority: P3
dependencies: []
---
# Phase 3: Golden-table testdata and docs
## Overview
Freeze the verified 1800–2199 month structure as committed testdata, and close the resolved open
questions in `docs/amlich-known-issues.md`. Independent of phases 1–2.
## Requirements
- Functional: a test recomputes every lunar year's structure from the engine and compares
byte-exact against `testdata/lunar-years-1800-2199.txt`; `go test -run TestGoldenTable -update`
regenerates the file (standard `-update` flag idiom).
- File format, one line per lunar year:
`YYYY L n1 n2 ... nN` — `L` = leap month number (0 = none), `n*` = month lengths (29/30) in
chronological order, tháng 1 first, leap month inserted in sequence after month `L`
(12 entries normal year, 13 leap year).
- Non-functional: file lives in `internal/modules/amlich/testdata/`; generation logic lives in the
test file only (no production code).
## Architecture
- Build each year from `lunarToSolar` month starts: for lunar year Y, JDs of tháng 1..12 (+ leap
where `lunarToSolar(1, m, Y, true)` succeeds), sorted chronologically; lengths = successive
start-JD differences, last month's length from tháng 1 of Y+1. Simpler than sweeping days and
exercises the lunar→solar direction the round-trip test already covers from the other side.
- Rationale over round-trip: `TestSolarLunarRoundTrip` proves self-consistency only; a future
engine change that shifts a boundary consistently in both conversions passes it. The golden file
pins the actual verified placement.
## Related Code Files
- Create: `internal/modules/amlich/testdata/lunar-years-1800-2199.txt`
- Modify: `internal/modules/amlich/lunar_test.go` (or new `golden_test.go` if it crowds the file)
- Modify: `docs/amlich-known-issues.md`
## Implementation Steps
1. Write `TestGoldenTable` with `var update = flag.Bool("update", false, ...)`; generator builds the
full table string; when `-update`, write file and skip compare; else compare byte-exact with a
diff-friendly failure message (first differing line).
2. Generate the file once; eyeball spot checks: 2025 leap 6, 2028 leap 5, 2033 leap 11, 1944
leap 4, 1967 no leap month (leap numbers already pinned by `TestLeapMonthTable` — must agree;
the doc's "30/5 Đinh Mùi" for 1967 is day 30 of the regular month 5, not a leap month).
3. Commit the generated file. Edge-case years must round-trip with the existing suite untouched.
4. Update `docs/amlich-known-issues.md`:
- Open question 1 → resolved: caveat line added, months touching the boundary only.
- Open question 2 → closed (don't build): add the South-Vietnam wrinkle (UTC+7 until 1959,
UTC+8 1960–67 per Hồ Ngọc Đức's historic-calendar page) — a single UTC+8 mode would be wrong
for the South 1955–59, strengthening the existing conclusion.
- "`/duonglich` defaults inside leap months" item → note the hint now self-disambiguates.
- Keep questions 3 (ΔT) and 4 (future official tables) open; add one line noting the 2026 CGPM
leap-second-abolition vote as a further far-future timescale uncertainty in the same bucket.
## Success Criteria
- [ ] Golden file committed; regeneration from HEAD is byte-identical
- [ ] Golden leap months agree with `TestLeapMonthTable` pins
- [ ] Docs updated; no stale claims left in the two resolved items
- [ ] `docs/amlich-known-issues.md` stays under docs.maxLoc (800)
## Risk Assessment
- Ordering bug when inserting the leap month (nhuận m sorts after regular m) — chronological
sort by start JD avoids hand-rolled index math.
- `flag.Bool` at package scope collides if a flag named `update` ever exists elsewhere in the
package's tests — it doesn't today; keep the name.
@@ -1,64 +0,0 @@
---
title: >-
Amlich converter improvements: leap-month hint, razor-edge caveat,
golden-table tests
description: >-
Three merged non-breaking improvements to internal/modules/amlich from
brainstorm decision
status: completed
priority: P2
branch: main
tags:
- amlich
- telegram-bot
blockedBy: []
blocks: []
created: '2026-08-18T15:04:11.318Z'
createdBy: 'ck:plan'
source: skill
---
# Amlich converter improvements: leap-month hint, razor-edge caveat, golden-table tests
## Overview
Implement the three improvements selected in
`plans/reports/brainstorm-decision-260818-2158-amlich-improvements-selection-report.md`
(research: `plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md`):
1. `/duonglich` leap-month ambiguity hint — when the entered month is also that year's leap month
and no `nhuan` flag given, append a one-line hint.
2. Razor-edge caveat — both commands warn when the result's lunar month touches one of the 7
disputed month boundaries from 2072 on (documented in `docs/amlich-known-issues.md`).
3. Golden-table regression testdata — freeze the verified 1800–2199 month-structure output.
Explicitly out of scope (rejected with verification in the research report): ΔT model update,
pre-1968 historic mode, table-driven engine rewrite, range extension, extra calendar features.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Lunar core helpers](./phase-01-lunar-core-helpers.md) | Completed |
| 2 | [Handler replies](./phase-02-handler-replies.md) | Completed |
| 3 | [Golden-table testdata and docs](./phase-03-golden-table-testdata-and-docs.md) | Completed |
## Dependencies
None cross-plan. Phase 2 depends on phase 1; phase 3 is independent.
## Acceptance Criteria (whole plan)
- All existing tests pass unchanged — especially `knownDates` pins (20/6/1944, 7/7/1967),
`TestSolarLunarRoundTrip`, `TestLeapMonthTable`.
- `/duonglich 5/5/2028` (leap-5 year) shows the hint; `/duonglich 5/5/2028 nhuan` and non-leap
years do not.
- Conversions inside a lunar month adjacent to the 09/12/2072 boundary show the caveat (both
commands); ordinary dates do not.
- Golden file regenerated from HEAD is byte-identical to the committed one.
- No public-contract changes; reply format only gains optional trailing lines.
- `go vet ./...` and `staticcheck` clean (repo standard).
## Validation
`go test ./internal/modules/amlich/` after each phase; full `go test ./...` + lint at the end.
@@ -1,160 +0,0 @@
---
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.
@@ -1,188 +0,0 @@
---
phase: 2
title: "Phase 2: Store, set names, emoji parsing"
status: done
priority: P1
effort: "3h"
dependencies: [1]
---
# Phase 2: Store, set names, emoji parsing
## Overview
Pure, Telegram-free foundation: the persisted pack record, the mapping between a
user-chosen slug and a Telegram set name, sender validation, and emoji-argument parsing.
Every function here is unit-testable without a bot.
One pack per user means the store layer is a single keyed record, not a collection scan.
## Requirements
- Functional: persist at most one pack record per user, keyed so the caller's own user ID
is the whole key — making the lookup itself the ownership check.
- Functional: construct a Telegram set name `<slug>_by_<bot_username>` at creation, and match
a sticker's `set_name` against the stored pack **without** re-deriving it from the live
username.
- Functional: reject senders that are bots or anonymous chat surrogates.
- Functional: split an emoji argument run into individual emoji, accepting `😂 🔥` and `😂🔥`.
- Non-functional: no network calls and no Telegram API types in the store layer.
## Architecture
### Pack record
```go
// Pack is the single bot-created sticker set owned by a Telegram user.
type Pack struct {
Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL
Name string `bson:"name"` // Telegram set name, "<slug>_by_<botname>"
Title string `bson:"title"` // display title, mutable
OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to
Count int `bson:"count"` // stickers in the set; keeps /mypack API-free
Pending bool `bson:"pending"` // write-ahead intent; see Phase 3
CreatedAt int64 `bson:"createdAt"` // unix millis
}
```
- **Key: `strconv.FormatInt(ownerID, 10)`** — the user ID alone. One pack per user makes the
slug unnecessary as a key component, which removes the prefix scan entirely.
- `getPack(ctx, ownerID) (Pack, bool, error)` — one `Get`; `storage.ErrNotFound` maps to
`false, nil`. There is no `listPacks` and no `List` call anywhere in the module.
This retires the worst red-team finding. The previous `listPacks` was a structural N+1
(`mongo_doc_store.go:157-165` projects `_id` only, forcing a `Get` per key), and
`/packlist` layered ten `GetStickerSet` calls on top of it under a 60s-per-call ceiling.
- `Count` keeps `/mypack` free of API calls. It is **advisory** — a user editing the pack
through @Stickers desyncs it. Phase 3 refreshes it opportunistically.
- `Pending` implements write-ahead intent (plan C5). Phase 3 owns the state machine.
- `Pack`'s bson tags must not collide with `_id` / `version` / `updatedAt`; `storage.Typed`
panics on collision (`internal/storage/doc_store.go:72`).
### Sender validation
```go
// senderID returns the personal Telegram user behind msg, or an error when the
// message has no usable personal identity.
func senderID(msg *models.Message) (int64, error)
```
Rejects, in order: nil `msg`/`From`, zero ID, `From.IsBot`, and non-nil `msg.SenderChat`.
The last two are not theoretical. Telegram substitutes a single global `GroupAnonymousBot`
user for **every** anonymous group-admin message and puts the real origin in `SenderChat`
(`models/message.go:86-87`). Without this check, all anonymous admins across all groups would
share one pack — and under one-pack-per-user that is worse than it was before, because the
first anonymous admin to run `/newpack` would block every other one and own the result.
`rg "IsBot" internal/` returns zero hits today; `coin`, `gold`, and `stock` check only
`From != nil && From.ID != 0`, which is safe for paper-trading state but not for durable
Telegram objects.
The refusal must explain the fix ("sticker packs need a personal account — turn off anonymous
posting for this message"), not just deny.
### Set names
- `slugRe = ^[a-z][a-z0-9_]{2,39}$` — 3 to 40 chars. Additionally reject `__` (Telegram
forbids consecutive underscores) and a trailing `_`. The cap is for link readability and to
stay inside the 64-char set-name budget.
- `makeSetName(slug, botUsername) (string, error)` → `slug + "_by_" + botUsername`, erroring
above 64 chars and reporting the remaining slug budget so the reply can say "max N
characters".
- **`ownsSet(pack Pack, setName string) bool`** — case-insensitive comparison of `setName`
against the stored `pack.Name`. This is the ownership resolver used by Phase 4.
It deliberately replaces a `parseSlug(setName, botUsername)` design that re-derived the slug
from the *live* username and discarded the persisted `Pack.Name`. Renaming the bot in
BotFather — supported, and it leaves existing set names untouched — would have made every
user's own pack refuse as "not yours" while `/mypack` still displayed it. Comparing the
stored name also removes a case-sensitivity trap, since Telegram returns `SetName` with
whatever casing the set was created with (plan R8).
- `usernameResolver` caches `GetMe` and **must not cache failures**. The bot starts with
`bot.WithSkipGetMe()` (`internal/telegram/client.go:26`), so nothing populates a username
until the module asks. It is used **only** by `/newpack` to name a new set — never for
ownership. It takes the handler's `b *bot.Bot`, **not** `deps.Bot`, which is documented
nil-safe (`internal/modules/module.go:88`) and is nil under `BuildOptions{}`
(`cmd/server/command_menu_test.go:55`).
### Emoji parsing
`parseEmoji(args []string) ([]string, error)` — join arguments, then split into clusters:
- keep ZWJ (`U+200D`) sequences together;
- absorb variation selectors (`U+FE0F`/`U+FE0E`), skin-tone modifiers (`U+1F3FB`–`U+1F3FF`),
and combining marks into the preceding cluster;
- pair regional indicators (`U+1F1E6`–`U+1F1FF`);
- keep keycap sequences (`<base> U+FE0F U+20E3`) together.
Reject non-emoji text with a usage error. Cap at 20 (`emoji_list` is documented 1–20; the
server's own message is the literal `too many emoji specified`). `defaultEmoji = "⭐"`.
Because `/addsticker` now takes only `[emoji...]`, every one of its arguments is an emoji —
there is no first-token disambiguation to perform, and a stray word fails loudly here rather
than being mistaken for a pack name.
`models.Sticker.Emoji` is a **single string** (`models/sticker.go:23`), so emoji inherited
from a replied sticker yields at most one element.
## Related Code Files
- Create: `internal/modules/sticker/pack.go`, `setname.go`, `sender.go`, `emoji.go`
- Create: matching `_test.go` files for each
- Reference: `internal/storage/doc_store.go:38` (DocStore contract), `keys.go:24-41`
- Reference: `internal/modules/coin/handlers_test.go:36` (memory-store test pattern)
- Reference: `models/message.go:86-87` (`SenderChat`), `models/user.go:12` (`IsBot`),
`models/sticker.go:23` (`Emoji` is one string)
## Implementation Steps
1. `pack.go` — record, `packKey`, `getPack`.
2. `sender.go` — `senderID` with the bot/anonymous refusals.
3. `setname.go` — slug validation, `makeSetName`, `ownsSet`, cached resolver interface.
4. `emoji.go` — cluster scanner and `defaultEmoji`.
5. Tests per the Todo list.
## Todo
- [x] Define `Pack` incl. `Count` and `Pending`; assert no reserved-bson collision
- [x] `packKey(ownerID)` and `getPack` returning a found flag
- [x] `senderID` rejecting nil/zero/`IsBot`/`SenderChat` with an explanatory message
- [x] `slugRe` validation incl. `__`, trailing `_`, 40-char cap
- [x] `makeSetName` with 64-char guard and budget-reporting error
- [x] `ownsSet` case-insensitive match against stored `Pack.Name`
- [x] `usernameResolver` caching success but never failure, taking the handler's `b`
- [x] `parseEmoji` cluster scanner with the 20-entry cap
- [x] Four test files per the success criteria
## Success Criteria
- [x] `getPack` for owner A never returns owner B's pack
- [x] `getPack` on an unknown owner returns `found == false` and a nil error
- [x] No `List` call exists anywhere in the module
- [x] Slug table rejects leading digit, `__`, trailing `_`, 2 chars, 41 chars
- [x] `makeSetName` errors when `len(slug)+len("_by_"+username) > 64`
- [x] `ownsSet` matches `MyPack_by_Bot` against a stored `mypack_by_bot`
- [x] `ownsSet` returns false for a set name belonging to another bot
- [x] A simulated bot username change does **not** break `ownsSet` for an existing pack
- [x] `senderID` rejects `IsBot: true` and a non-nil `SenderChat`, each with zero store access
- [x] `parseEmoji` handles joined input, ZWJ family, flag, keycap, skin tone; rejects plain text; errors above 20
- [x] `gofmt -l internal/modules/sticker` empty; `go test`/`go vet` clean
## Risk Assessment
**Emoji cluster scanning is hand-rolled.** Go has no stdlib grapheme segmentation and a
dependency for this is disproportionate. Signal: a user reports an emoji split or rejected.
Response: extend the table-driven test with the failing sequence. If failures accumulate
across many scripts, take a segmentation dependency.
**`Count` can drift.** Editing the pack through @Stickers changes the real count without the
bot seeing it. Accepted: the field is advisory and feeds one display column. Phase 3 refreshes
it whenever a command already holds a `GetStickerSet` response, so it self-heals without any
command paying for a lookup it did not otherwise need.
**Keying on the user ID alone bakes in the one-pack limit.** Reversing to multiple packs later
means a key migration, not just new commands — every existing record would need rewriting
under a compound key. That is the real cost of plan R10, and it is why the limit belongs in
the plan rather than living as a constant someone can bump.
@@ -1,275 +0,0 @@
---
phase: 3
title: "Phase 3: Pack lifecycle commands"
status: done
priority: P1
effort: "7h"
dependencies: [1, 2]
---
# Phase 3: Pack lifecycle commands
## Overview
`/newpack`, `/mypack`, `/renamepack`, `/delpack` (+ confirm callback). These own creation and
destruction of the user's single pack, and they carry the plan's two hardest correctness
problems: surviving partial failure, and making an irreversible delete safe to confirm.
All handlers follow the plan's cross-cutting rules (explicit deadline, `WithoutCancel`
commits, `senderID`, positive error classification).
## Requirements
- Functional: create the caller's pack and persist its record such that no interruption can
permanently strand the slug.
- Functional: show, rename, and delete that pack, never another user's.
- Functional: a second `/newpack` while a pack exists is refused with a clear next step.
- Functional: `/delpack` confirmation is bound to invoker, chat, message, and a TTL.
- Functional: `/mypack` makes zero API calls.
- Non-functional: a store record must never claim a pack that does not exist, and a live pack
must never lose its record because of a transient error.
## Architecture
### Factory
Mirrors `internal/modules/coin/coin.go`. `state` holds `store`, `pending` (a second typed view
for delete confirmations — the pattern is idiomatic here; `loldle` and `lol` each build three
views over one collection), `resolver`, `locks keylock.Map`, `nowFn`.
Registry key is `sticker`; command names stay unprefixed — the registry keys commands by
`cmd.Name` independent of module name (`registry.go:176`), which is why `misc` ships `/ff`.
`handlerTimeout = 10 * time.Second` is a package constant; every handler opens with it.
> **Superseded during implementation — see plan.md, "post-implementation review:
> global slug reservation".** Step 5 below adopts an existing set on the strength
> of a `Pending` record for this owner and slug. Review proved that insufficient:
> the pack record is keyed by owner, so a user with *no* pack who types someone
> else's slug produces identical evidence and took over their pack. The shipped
> code adds a create-only global reservation (`slug:<slug>` → ownerID) written
> before Telegram is touched, and adopts only when it names the caller. Steps 4-7
> read as implemented **except** that a reservation check precedes step 4, and
> step 5's "abort, delete the pending record" on an unknown error is wrong for
> the same reason rule 4 exists — the shipped code keeps both the intent and the
> reservation unless the refusal is positively classified.
### `/newpack <pack> <title...>` — write-ahead intent
`<pack>` appears here and nowhere else in the module. It fixes the permanent share URL, and
Telegram has no rename-short-name method, so it cannot be corrected later.
An earlier draft did "create on Telegram, then write the store", accepting that an interruption
stranded the slug forever. Two review findings killed that: the commit ran on `rootCtx`, which
SIGTERM cancels, so **every deploy** during a `/newpack` stranded a slug; and the
"cannot adopt" rule was not forced by the API's missing owner field (plan C5). The bot does not
need the API to name the owner — it needs its own record of who asked.
1. `senderID(msg)`; `defer s.locks.Acquire(...)()`.
2. Parse slug and title (1–64 chars).
3. `makeSetName(slug, username)`.
4. **`PutVersioned(ctx, key, 0, Pack{Pending: true, …})`** — the create-only primitive
(`doc_store.go:33-37`; Mongo gives a linearizable single-winner via duplicate-key,
`mongo_doc_store.go:87-105`). This *is* the one-pack quota — no separate counter exists.
On `ErrConflict`, read the record:
- confirmed → "you already have a pack (`<slug>`). Use /delpack first." Stop.
- `Pending` with the **same** slug → this is our own interrupted attempt; resume at step 5.
- `Pending` with a **different** slug → an earlier attempt was interrupted. **Probe
`GetStickerSet(oldName)` before doing anything.**
- The old set **exists** → the earlier attempt got as far as creating it. Adopt the old
set, commit it, and tell the user they already have a pack (`<oldSlug>`) and must
`/delpack` first if they want the new name. Do **not** overwrite.
- The old set is **missing** (`isStickerSetMissing`) → nothing was created; overwrite the
pending record with the new slug and continue.
- **Any other error** → unknown; abort without touching the record.
Overwriting unconditionally would orphan a created-but-uncommitted set permanently: the
set exists and is owned by the user, but adoption keys on the pending slug matching, so
`/newpack <oldSlug>` would afterwards report "taken" with no route back. The probe is what
makes the different-slug branch safe.
Use `Put` nowhere here — it is a 5-attempt Get→PutVersioned loop
(`mongo_doc_store.go:120-142`) that silently overwrites.
5. `GetStickerSet(name)`:
- **succeeds** → the set exists. We hold a `Pending` record for this owner and slug, so this
is our own interrupted attempt: **adopt it**, jump to step 7.
- **`isStickerSetMissing`** → the slug is free; proceed to step 6.
- **any other error** → unknown. Abort, delete the pending record, reply generic failure.
Never guess (plan rule 4).
6. `CreateNewStickerSet{UserID, Name, Title, Stickers: []InputSticker{{Sticker: fileID, Format: "static", EmojiList: emoji}}}`.
No top-level `sticker_format` — it moved to `InputSticker.Format` in Bot API 7.2 (C7).
On `PACK_SHORT_NAME_OCCUPIED`, another user of this bot holds the slug: delete the pending
record and ask for a different one.
7. Commit: `Put(context.WithoutCancel(ctx), key, Pack{Pending: false, Count: 1, …})`.
Reply with the title and `https://t.me/addstickers/<name>`.
Re-running `/newpack` with the same slug after any interruption completes the operation instead
of reporting it taken. That is the plan's "interrupted `/newpack` can be completed by re-running"
criterion.
### `/mypack` — zero API calls
`getPack(senderID)`. One `Get`. Renders slug, title, `Count`, and the share link, or a short
"you don't have a pack yet — `/newpack <name> <title>`" when absent. A `Pending` record renders
with an "(incomplete — re-run /newpack)" marker rather than being hidden, so a stranded attempt
is visible and fixable.
This replaces the multi-pack `/packlist`, which issued one `GetStickerSet` per pack. Under plan
C2 each call is bounded only by the library's 60s `http.Client` timeout
(`bot.go:17-18,75-77`), so ten of them on a serialized dispatcher was a ~10-minute bot-wide
freeze from one argument-free public command. That failure mode no longer exists.
### `/renamepack <title...>`
`getPack(senderID)`; absent → "you don't have a pack yet". `SetStickerSetTitle{Name, Title}`,
then commit the new `Title` under `WithoutCancel`.
The reply must state the share link is unchanged **and name the route to a different one**:
`/delpack` then `/newpack <new-slug>`. This matters more now that the command takes no slug — a
user typing "rename" with only a title is even likelier to expect the URL to follow, and it
never can. Pointing at the real path turns a dead end into an answer.
Required elements of the reply: the new title, the unchanged link, and the delete-and-recreate
route with its cost stated (the stickers do not come along).
**Reverse gap:** if the API succeeds and the commit fails, `/mypack` shows a title Telegram no
longer has. Cosmetic, self-heals on the next successful rename. Documented, not mitigated.
### `/delpack` — no argument, bound confirmation
An earlier draft put the slug in the callback data and re-checked ownership from
`CallbackQuery.From.ID`. That defends against *other* users pressing the button and nothing
else: the payload never expired, was not bound to a chat or message, and lived in scrollback
forever. Three reviewers flagged it independently, and the `stock` module the draft cited as its
model already solves it properly.
With one pack per user the payload needs no slug at all — but it still needs everything else:
1. `/delpack` resolves the caller's pack, then writes a pending action:
`pendingDelete{ID, OwnerID, Slug, ChatID, MessageID, ExpiresAt}` with
`pendingDeleteTTL = 10 * time.Minute`. (`stock/pending_dividend.go:12-16,26-34` uses 24h for
a non-destructive action; a destructive one earns a shorter window.)
The confirm prompt must state all four consequences before the tap, since the command itself
names nothing:
- the pack title being deleted;
- **the sticker count that will be lost** (`Pack.Count`);
- the exact share link that will stop working;
- that both are permanent.
`/delpack` is the sanctioned way to change a pack URL, so this prompt is the last point at
which a user learns the stickers do not survive the change. Understating it here is how
someone loses 47 stickers expecting a rename.
2. Callback data is `sticker_pack:d:<opaque id>` — comfortably inside the 64-byte cap (C3).
3. The callback handler:
- returns early when `update.CallbackQuery == nil`;
- resolves the pending action; absent → "this confirmation expired or was already used"
(`stock/dividend_callback.go:66-80` is the model);
- checks `query.From.ID == action.OwnerID` — identity from `From.ID`, **never** the payload;
- checks the chat/message binding and `ExpiresAt`;
- guards `query.Message.Message` for nil — it is a `MaybeInaccessibleMessage`
(`models/message.go:17-21`), nil for messages Telegram marks inaccessible.
`stock/dividend_callback.go:82-83` already guards exactly this. Phase 1's panic barrier is
the backstop, not an excuse to skip the guard;
- deletes the pending action **before** calling `DeleteStickerSet` (single-use);
- `DeleteStickerSet{Name}`, then `store.Delete` under `WithoutCancel`;
- `AnswerCallbackQuery` and clear the button via `EditMessageReplyMarkup` with empty markup,
using `action.ChatID`/`action.MessageID` (`dividend_callback.go:26-33`).
**Reverse gap:** if `DeleteStickerSet` succeeds and `store.Delete` fails, a phantom record
survives — and under one-pack-per-user that is worse than before, because it blocks `/newpack`
entirely rather than consuming one of ten slots. Mitigation: any command receiving
`isStickerSetMissing` from the API deletes the record on the spot, so the phantom clears on
first contact and `/newpack` works again.
### Error mapping
`replyAPIError` matches **MTProto code substrings**, never human text (plan rule 4 / R3). Only
`PACK_SHORT_NAME_OCCUPIED`, `PACK_SHORT_NAME_INVALID`, and `STICKER_EMOJI_INVALID` are rewritten
into prose by the Bot API server; everything else arrives as `Bad Request: <CODE>`.
| Match | Reply |
|---|---|
| `PACK_SHORT_NAME_OCCUPIED` / "already occupied" | slug taken, pick another |
| `PACK_SHORT_NAME_INVALID` / "invalid sticker set name" | slug rejected by Telegram |
| `PACK_TITLE_INVALID` | title rejected by Telegram |
| `STICKERSET_INVALID` | your pack no longer exists (and delete the record) |
| `STICKERS_TOO_MUCH` | pack is full (120 stickers) |
| `STICKER_EMOJI_INVALID` / "invalid sticker emojis" | emoji rejected |
| `too many emoji specified` | at most 20 emoji per sticker |
| anything else | generic failure; raw error to the dispatcher log |
## Related Code Files
- Create: `internal/modules/sticker/sticker.go`, `state.go`, `pack_handlers.go`,
`pending_delete.go`, `delpack_callback.go`, `errors.go`, and their tests
- Reference: `internal/modules/coin/coin.go`, `coin/handlers.go:51` (keylock idiom)
- Reference: `internal/modules/stock/pending_dividend.go:12-34,47-78`,
`stock/dividend_callback.go:17-33,66-83,99-102`, `stock/dividend_notifications.go:303-320`
- Reference: `internal/storage/doc_store.go:33-41`, `mongo_doc_store.go:87-142`
## Implementation Steps
1. `state.go`, `sticker.go` wiring four commands + the `sticker_pack:` callback.
2. `errors.go` with `replyAPIError` and `isStickerSetMissing`.
3. `/mypack` first — no mutations, no API calls, easiest to verify.
4. `/newpack` with the write-ahead state machine.
5. `/renamepack`.
6. `pending_delete.go`, `/delpack`, and the callback.
7. Tests per the Todo list, using Phase 1's `StubMethod` / `FailMethodCode`.
## Todo
- [x] `state.go` with store, pending view, resolver, locks, nowFn, `handlerTimeout`
- [x] `sticker.go` factory registering the module's commands + callback prefix (9 as shipped, once phases 4-5 landed)
- [x] `errors.go`: `replyAPIError` code table + `isStickerSetMissing`
- [x] `/mypack` reading `Count`, marking a pending record, zero API calls
- [x] `/newpack` steps 1-7 incl. `PutVersioned` intent, three `ErrConflict` branches, adoption
- [x] Different-slug pending branch probes `GetStickerSet(oldName)` before overwriting
- [x] `/renamepack` reply: new title, unchanged link, and the /delpack + /newpack route
- [x] `/delpack` confirm prompt: title, sticker count, link, permanence
- [x] `pending_delete.go` with TTL, chat/message binding, opaque id
- [x] `/delpack` naming the pack in its confirm prompt
- [x] `delpack_callback.go` with expiry, binding, nil-message guard, single-use
- [x] Record self-heal on `isStickerSetMissing` across commands
- [x] Tests per the success criteria
## Success Criteria
- [x] `/mypack` records **zero** entries in `RecordingBot.Sent()`
- [x] A second `/newpack` with a confirmed pack present is refused, names the existing slug, and makes zero API calls
- [x] Interrupted `/newpack` (pending record, same slug, set exists) completes on re-run and does not report the slug taken
- [x] Interrupted `/newpack` with a *different* slug where the old set **exists** adopts the old set and refuses the new slug, leaving nothing orphaned
- [x] Interrupted `/newpack` with a *different* slug where the old set is **missing** replaces the pending record and proceeds
- [x] `/newpack` where `GetStickerSet` fails with a non-missing error aborts and never calls `CreateNewStickerSet`, **keeping** the pending record and the reservation — superseded, see the note at the top of this file. Deleting them on an unknown error is what strands a slug: the set may exist, and re-running is how the user recovers.
- [x] `/newpack` uses `PutVersioned(…, 0, …)`; the create path never calls `Put` for a new record
- [x] `/renamepack` with no pack replies "you don't have a pack yet" and makes zero API calls
- [x] `/delpack` confirm after `ExpiresAt` is refused as expired, with no `DeleteStickerSet`
- [x] `/delpack` confirm from a different `From.ID` is refused, with no `DeleteStickerSet`
- [x] `/delpack` confirm with a nil `CallbackQuery.Message.Message` is handled without panic
- [x] Pressing the same confirm twice deletes once; the second press reports already-used
- [x] Callback data is asserted ≤ 64 bytes
- [x] A command receiving `STICKERSET_INVALID` deletes the stale record, unblocking `/newpack`
- [x] Title of 65 chars rejected locally, before any API call
- [x] `/delpack` confirm text contains the pack title, the sticker count, and the share link
- [x] `/renamepack` reply names the `/delpack` + `/newpack` route
## Risk Assessment
**The write-ahead state machine is the most intricate logic in the plan**, and one-pack-per-user
adds a branch rather than removing one: `ErrConflict` now means three different things
(confirmed pack, own pending same-slug, own pending different-slug). Its correctness rests on
one property — a `Pending` record for an owner means *that owner* asked for *that name*, and
only this bot can create `*_by_<bot_username>` names. If either half stops holding, adoption
becomes unsafe. Signal: a user reports adopting a pack they did not create. Response: disable
adoption (step 5 becomes "slug taken") and fall back to the documented orphan gap — a one-line
change, deliberately.
**A stranded pending record now blocks the user entirely.** With ten slots it cost one; with one
pack it blocks `/newpack` until resolved. Mitigated by making it visible in `/mypack` with a
re-run hint, and by the different-slug overwrite branch in step 4 so a user is never wedged by
a name they no longer want.
**`/delpack` remains irreversible on Telegram's side.** The TTL and bindings reduce accidental
confirmation; they cannot undo a deliberate one. Do not add a `--force` bypass.
@@ -1,240 +0,0 @@
---
phase: 4
title: "Phase 4: Sticker commands (reply path)"
status: done
priority: P1
effort: "4h"
dependencies: [1, 2, 3]
---
# Phase 4: Sticker commands (reply path)
## Overview
Per-sticker operations on the caller's pack: `/addsticker` (existing-sticker source),
`/delsticker`, `/editsticker`, `/ordersticker`. All are driven by replying to a sticker. The
photo source arrives in Phase 5 through the same `/addsticker` handler.
None of these takes a pack argument. Under one-pack-per-user there is nothing to name.
All handlers follow the plan's cross-cutting rules.
## Requirements
- Functional: add an existing static sticker to the caller's pack.
- Functional: remove, re-emoji, and reposition a sticker already in that pack.
- Non-functional: ownership is checked before any API call, and the refusal text is identical
for "another user's pack" and "another bot's pack".
- Non-functional: no transient error may delete a live pack's record.
## Architecture
### Shared resolution
```go
// source of a NEW sticker: whatever the replied message carries. Resolution
// always ends in a file_id usable as InputSticker.Sticker.
type stickerSource struct {
fileID string // static sticker file_id, or a freshly uploaded one (Phase 5)
emoji []string // from the replied sticker; at most one element
}
func (s *state) resolveSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error)
// an EXISTING sticker in the caller's pack
type ownedSticker struct {
fileID string
pack Pack
}
func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, error)
```
`resolveSource` takes `ctx`, `b`, and `ownerID` from the start even though the sticker branch
uses none of them. Phase 5's photo branch needs all three (`GetFile`, an HTTP download,
`UploadStickerFile{UserID}`), and it lives **inside this function**. Declaring the full
signature now means Phase 5 adds a branch instead of rewriting every call site. There is no
`photoRef` field: the photo path resolves to a `fileID` like every other path.
Its gate:
1. Require `msg.ReplyToMessage`; else usage error.
2. Sticker branch — reject `IsAnimated`, `IsVideo`, **and `Type != "regular"`**
(`models/sticker.go:16`). A mask or custom-emoji sticker is static yet invalid for a
regular set, so the `IsAnimated || IsVideo` pair alone does not close this. The static-only
gate belongs here, on the path that can actually receive a non-static sticker — not only in
`resolveOwned`, which by construction only ever sees stickers already in a static pack.
3. Otherwise (Phase 5) the photo/document branch; until then, a usage error.
`resolveOwned` is the single ownership gate for `/delsticker`, `/editsticker`, `/ordersticker`,
and (Phase 5) `/setpackicon`:
1. Require `msg.ReplyToMessage.Sticker`; else usage error.
2. Require a non-empty `Sticker.SetName`.
3. Reject `IsAnimated`, `IsVideo`, or `Type != "regular"`. Static-only module; defence in
depth. **Before** the store read, so a malformed reply costs nothing and the "rejected
before any API call" criterion holds trivially.
4. `getPack(ownerID)` — **one `Get`**. Absent → the caller has no pack.
5. `ownsSet(pack, sticker.SetName)` (Phase 2) — case-insensitive against the **stored**
`Pack.Name`. False → the sticker is not from the caller's pack.
6. **Steps 4 and 5 must produce byte-identical reply text.** Distinct messages would let a user
probe whether a given set belongs to someone else. Pack *management* refusals stay uniform
even though `/newpack` deliberately discloses slug occupancy (plan's Accepted disclosure
section) — those are different questions.
This replaced a `listPacks` + match design; with one pack it collapses to a single `Get` and a
string comparison. It still compares against the stored `Pack.Name` rather than a slug
re-derived from the live bot username — Phase 2 explains why (plan R8): a BotFather rename would
otherwise make the user's own pack refuse as "not yours" while `/mypack` still displayed it.
`models.Sticker.Emoji` is a single string (`models/sticker.go:23`), so `stickerSource.emoji`
from a replied sticker holds at most one element.
### `/addsticker [emoji...]`
Reply required. Pack from `getPack`, source from `resolveSource`. Emoji precedence: explicit
args → the replied sticker's emoji → `defaultEmoji`.
**No pack yet** → "you don't have a pack yet — `/newpack <name> <title>`", zero API calls.
This reply is deliberately **not** the uniform `resolveOwned` refusal, and the difference is
not an oversight: `resolveOwned` is uniform because it answers a question about a set the
caller named, which may be someone else's. `/addsticker` answers only "do *you* have a pack",
about the caller's own state, and discloses nothing about anyone else. Being helpful here
costs no privacy. A `Pending` record counts as no usable pack — same reply, plus the
`/mypack` re-run hint from Phase 3.
Every argument is an emoji — there is no pack token to disambiguate, so a stray word is caught
by `parseEmoji` and reported as a usage error rather than silently read as a pack name.
`AddStickerToSet{UserID: ownerID, Name: pack.Name, Sticker: InputSticker{Sticker: fileID, Format: "static", EmojiList: emoji}}`.
`UserID` is the pack owner, always the caller — the module never lets a non-owner reach this
call. Take the per-user keylock. On success, increment `Pack.Count` and commit under
`WithoutCancel`. `STICKERS_TOO_MUCH` maps to "your pack is full (120 stickers)".
**Unverified premise — settle before writing this handler.** The whole sticker-source path
assumes `AddStickerToSet` accepts a `file_id` for a sticker that lives in a set this bot did
not create. The Bot API documents `InputSticker.sticker` as accepting "a file_id as a String
to send a file that already exists on the Telegram servers", and says nothing further — but
unlike C1–C11 this was never checked against the live API, and every other API assumption in
this plan was. If Telegram rejects cross-set reuse, this path collapses into Phase 5's
machinery (`GetFile` → download → `UploadStickerFile` → use the returned `file_id`), which
inverts the 4→5 dependency and is much better known before the handler is written than after.
One live call settles it (plan R12).
### `/delsticker`
No arguments. `resolveOwned`, then `DeleteStickerFromSet{Sticker: fileID}`. On success,
decrement `Pack.Count` (floor 0) and commit under `WithoutCancel`.
**Take the per-user keylock**, exactly as `/addsticker` does. Both run the same
read-modify-write on `Pack.Count`, so locking one and not the other would be a half-measure
that only looks safe. `/editsticker` and `/ordersticker` write nothing and take no lock.
Under the lock, the commit uses plain `Put` — the lock is what makes the read-modify-write
safe, so the `PutVersioned` reasoning from Phase 3 (which guards *creation*, not updates)
does not apply.
Whether removing the final sticker also destroys the set is **not documented** in the Bot API
docs or the open-source Bot API server, so the plan depends on neither answer.
An earlier draft probed with `GetStickerSet` afterwards and deleted the local record when the
probe "reported not-found" — but never defined how not-found differs from a failed call, and its
own success criterion (`FailMethod` → "record confirmed removed") specified the destructive
reading. Under that design a 429, a DNS blip, or a SIGTERM-cancelled context during a routine
delete would erase the only record of a live pack. With one pack per user that is strictly
worse than it was: the user loses their pack *and* is blocked from `/newpack` until the phantom
clears.
Corrected: **do not probe.** Decrement the count and stop. If the set really is gone, the next
command returns `STICKERSET_INVALID`, and the shared handler for that (Phase 3) deletes the
record then — a positive signal, per plan rule 4. Simpler and strictly safer.
**Aftermath of a `Count: 0` pack — state the recovery route.** Not probing means that if
Telegram *did* destroy the set, the user is left holding a record for a pack that no longer
exists. `/mypack` makes zero API calls by design, so it cannot notice; `/newpack` refuses
because a record exists. The user is not wedged — `/delpack` calls `DeleteStickerSet`, gets
`STICKERSET_INVALID`, and Phase 3's self-heal drops the record, freeing `/newpack` — but
nothing in the plan told them that, and this phase is what creates the situation. So: when
the decrement lands on 0, the reply says the pack is now empty, that Telegram may have
removed it, and that `/delpack` clears it if `/addsticker` reports the pack is gone.
This is also why the success criterion below is scoped rather than absolute. `/delsticker`
must never delete the `Pack` record on a **transient or unknown** error — that is finding R7,
the whole reason the probe was removed. It must still delete it on a **positive**
`STICKERSET_INVALID`, which is Phase 3's cross-command self-heal and the mechanism that
unwedges the user. An unqualified "never deletes the record" would forbid the fix.
### `/editsticker <emoji...>`
`resolveOwned`, `parseEmoji` (at least one required — an empty `emoji_list` is invalid), then
`SetStickerEmojiList{Sticker: fileID, EmojiList: emoji}`.
### `/ordersticker <position>`
`resolveOwned`, parse a non-negative integer, reject negatives locally. 0-based, stated in the
usage text. Do **not** bound the upper end locally — Telegram validates against the current set
size and a local copy would go stale. Its error goes through `replyAPIError`.
`SetStickerPositionInSet{Sticker: fileID, Position: pos}`.
## Related Code Files
- Create: `internal/modules/sticker/resolve.go`, `sticker_handlers.go`, and their tests
- Modify: `internal/modules/sticker/sticker.go` (register four commands)
- Reference: `internal/modules/util/handlers_test.go:108-118` — an existing test synthesizing
`ReplyToMessage` with a `models.Sticker{FileID, FileUniqueID, SetName, Emoji}`. Exactly the
fixture shape every test here needs.
- Reference: `internal/testutil/update_builders.go` (`NewPrivateMessage`, `NewGroupMessage`)
## Implementation Steps
1. `resolve.go` with both helpers and the deliberately uniform not-owned reply.
2. The four handlers in `sticker_handlers.go`, each opening with `handlerTimeout`.
3. Register with `Parameters` per `docs/command-parameter-conventions.md`:
`[emoji...]`, none, `<emoji...>`, `<position>`.
4. Tests per the Todo list.
## Todo
- [x] Settle the `file_id`-reuse premise against the live API before writing `/addsticker`
- [x] `resolveSource` with the full `(ctx, b, ownerID, msg)` signature, sticker branch only
- [x] `resolveSource` static-only gate: `IsAnimated`, `IsVideo`, `Type != "regular"`
- [x] `resolveOwned` using `getPack` + `ownsSet`, with the 6-step gate and uniform refusal
- [x] `/addsticker` with emoji precedence and `Count` increment
- [x] `/addsticker` no-pack and pending-pack replies, zero API calls
- [x] `/delsticker` with `Count` decrement, keylock, and **no** probe
- [x] `/delsticker` empty-pack reply naming the `/delpack` recovery route
- [x] `/editsticker` requiring at least one emoji
- [x] `/ordersticker` rejecting negatives locally only
- [x] Register all four with `Parameters` metadata
- [x] `resolve_test.go`, `sticker_handlers_test.go`
## Success Criteria
- [x] Each command's happy path asserts the expected method in `RecordingBot.Sent()`
- [x] Missing reply, non-sticker reply, and empty `set_name` each produce a usage error with zero API calls
- [x] "No pack yet" and "sticker from another bot's set" produce **byte-identical** reply text, asserted by comparing the two replies to each other
- [x] A sticker whose `SetName` differs only in case from the stored `Pack.Name` resolves successfully
- [x] `/addsticker` with a non-emoji argument is rejected by `parseEmoji`, not silently reinterpreted
- [x] `/ordersticker -1` rejected locally; `/ordersticker 999` reaches the API
- [x] `/editsticker` with no emoji rejected locally
- [x] `/delsticker` makes exactly one API call, and keeps the `Pack` record on a transient or unknown error
- [x] `/delsticker` receiving `STICKERSET_INVALID` **does** delete the record (Phase 3 self-heal), unblocking `/newpack`
- [x] `/addsticker` and `/delsticker` move `Count` by exactly one, floored at 0
- [x] A `/delsticker` that lands on `Count: 0` names `/delpack` in its reply
- [x] `/addsticker` with no pack, and with a `Pending` pack, each reply with zero API calls
- [x] `/addsticker` on a full pack maps `STICKERS_TOO_MUCH` to the "pack is full" reply (via Phase 1 `FailMethodCode`)
- [x] Animated, video, **and mask/custom-emoji** (`Type != "regular"`) replies rejected before any API call, on both the source and owned paths
## Risk Assessment
**The uniform-refusal requirement is easy to regress.** A later contributor improving the error
copy could split the two messages and reintroduce the disclosure. Mitigation: the test asserts
equality *between the two paths' replies* rather than asserting two fixed strings, so the intent
survives a rewrite of the copy.
**`resolveOwned` now costs a single `Get`** — no `List`, no fan-out, no API call. This is the
one place the one-pack revision made a correctness-critical path cheaper as well as simpler,
and it removes the caching follow-up the multi-pack version needed.
**`Count` drift is user-visible but harmless.** Editing the pack through @Stickers desyncs it.
Phase 3 refreshes it whenever a command already holds a `GetStickerSet` response, so it
self-heals without any command paying for a lookup it did not otherwise need.
@@ -1,212 +0,0 @@
---
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).
@@ -1,203 +0,0 @@
---
phase: 6
title: "Phase 6: Wiring, menu, docs"
status: partial
priority: P2
effort: "4h"
dependencies: [1, 2, 3, 4, 5]
---
# Phase 6: Wiring, menu, docs
## Overview
Register the module, make enabling it an explicit operator decision, and bring the user-facing
surfaces named in `AGENTS.md` § "Command Changes" into line. Nothing from Phases 2–5 is
reachable by a user until this phase lands.
## Requirements
- Functional: the module is enabled only by an explicit `MODULES` entry.
- Functional: all nine commands appear in `/help` and the native menu with correct metadata.
- Non-functional: `/help` stays under the 4096-rune ceiling the test suite enforces.
- Non-functional: no stats migration — nothing is renamed or deleted.
## Architecture
### Enablement must come before registration
`internal/modules/registry.go:107-116` expands an empty `MODULES` to **every** registered
factory, and its own comment calls that the documented contract. The repo ships
`.env.example:16` as `MODULES=` (empty) and `compose.yml:14` documents "empty = all modules".
So adding `"sticker": sticker.New` to `factories()` **is** the enablement: a public,
write-capable module would go live on the next deploy with no operator decision. An earlier
draft claimed the opposite in three places — goal, requirement, and a success criterion a
reviewer would have ticked without testing.
Ordered fix, per the user's decision:
1. **First**, set `MODULES` explicitly in the deployed environment to the current eleven
modules, and verify the bot restarts with an unchanged command set:
`util,misc,amlich,monkeyd,wordle,loldle,lol,stock,gold,coin,stats`
2. **Then** add the `factories()` entry and merge.
3. Add `sticker` to `MODULES` when the operator chooses to turn it on.
Only after step 1 is "remove `sticker` from `MODULES`" a genuine zero-deploy rollback. Before
it, that rollback means enumerating eleven module names into an empty variable under pressure.
Update `.env.example` with the explicit list and a comment stating why, so a fresh clone does
not reintroduce the empty-means-everything trap.
### Registration
One line in `factories()` (`cmd/server/main.go:83`). Plain string key, matching `util`, `misc`,
and `gold`; the `CollectionName` constant form in `lol`/`coin`/`stock` exists because those
packages reuse the name elsewhere, which this one does not.
`Build` validates names against `^[a-z0-9_]{1,32}$` (`validate.go:10`) and rejects duplicates
(`registry.go:173`). All nine were verified free against the current registry; re-run registry
tests to catch later additions. Note `mypack` replaced `packlist` in the one-pack revision —
re-verify that name specifically, since it was not part of the original conflict check.
### The `/help` rune budget
`cmd/server/command_menu_test.go:110-113` renders the full `/help` body and `t.Fatalf`s above
`telegramMessageMaxRunesForTest = 4096` (`:116-119`).
Measured at HEAD: **3212 runes, 45 public commands, 11 modules — 884 runes of headroom.**
The one-pack revision helps here. Each help line is
`InvocationSentence() + " " + SummarySentence()` (`internal/modules/command_presentation.go:16-24`),
so `Parameters` counts against the budget — and four commands lost their `<pack>` token:
| Command | Was | Now |
|---|---|---|
| `/addsticker` | `<pack> [emoji...]` | `[emoji...]` |
| `/renamepack` | `<pack> <title...>` | `<title...>` |
| `/delpack` | `<pack>` | — |
| `/packlist` → `/mypack` | — | — |
That is roughly 25 runes recovered across the module, leaving about **98 runes per command
line** including the module header rather than ~85. Still not generous: `/ordersticker <position>.`
alone is 24 runes, leaving ~74 for its description.
Write the nine descriptions against that budget *before* wiring, then re-measure — the figure
above is derived, not measured post-change. If they do not fit, decide then whether `/help`
needs pagination; that is a separate change, and it must not be "solved" by trimming other
modules' descriptions.
### Test impact — which tests, and which only look related
| Test | Uses real `factories()`? | Action |
|---|---|---|
| `main_test.go:111` `TestFactoriesIncludesExpectedModules` | No — builds only `{"gold","coin"}` | **No change needed** |
| `command_menu_test.go:19` `TestBotCommandMenu_...ModuleOrder` | No — synthetic `alpha`/`beta` | **No change needed** |
| `command_menu_test.go:54` `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` | **Yes** — `modules.Build(nil, factories(), …)` | **Add all nine to `expectedParameters`**; also enforces ≤256-rune descriptions, no `Eg:`, no newlines, and the 4096-rune `/help` ceiling |
| `command_menu_test.go:121` `TestBotCommandMenu_StockDividendContracts` | Stock-specific | No change |
`expectedParameters` entries for the nine: `newpack` → `<pack> <title...>`, `addsticker` →
`[emoji...]`, `delsticker` → ``, `editsticker` → `<emoji...>`, `ordersticker` → `<position>`,
`setpackicon` → ``, `renamepack` → `<title...>`, `delpack` → ``, `mypack` → ``.
**`<name...>` is not yet a documented form.** `docs/command-parameter-conventions.md:15-20`
defines `<name>`, `<name,...>`, `[name]`, and `[name...]` — required *remaining text* appears
nowhere, and no existing command uses it (`rg "Parameters:"` across `internal/` confirms).
Three of the nine (`<title...>` twice, `<emoji...>`) need it. Add the row
`| Required remaining text | \`<name...>\` | \`<title...>\` |` to that table and an example
line, in the same change that registers the commands — the conventions doc is the authority
these registrations are validated against, so shipping an undocumented form silently
demotes it.
### README and docs
Module table row listing the nine commands, then a `### Sticker packs` section: **one pack per
user**; the pack is created on behalf of the calling user; the bot manages only packs it
created; the slug is chosen once at `/newpack` and fixes a permanent share link that
`/renamepack` cannot change; static stickers and photos only; the 120-stickers-per-pack cap;
anonymous group admins are not supported and why.
Note the relationship to `/stickerid` in `util` — it stays put. It is a private debug helper for
reading a `file_id`, not pack management.
`docs/sticker-packs.md` carries: the full command reference with `Parameters` matching handler
usage text exactly (`docs/command-parameter-conventions.md` § Change Checklist); slug rules and
the permanence of the resulting URL; the image contract (512px long edge, PNG; thumbnails
100×100); what is deliberately absent and why (usage-statistics commands — no Bot API support;
animated/video/emoji packs — out of scope; multiple packs per user — see plan R10;
conversational flow — no message hook at `internal/modules/dispatcher.go:66`); the accepted
slug-occupancy disclosure; and the `Count` drift note.
### Stats compatibility
No command is renamed or deleted **in the shipped bot** — `/packlist` never existed outside this
plan, so its replacement by `/mypack` needs no migration. `AGENTS.md` § "Stats Compatibility"
governs renames of live commands and does not apply. New commands accrue stats through the
dispatcher hook (`dispatcher.go:80-84`).
## Related Code Files
- Modify: deployed environment `MODULES` (**before** the code change), `.env.example`
- Modify: `cmd/server/main.go` (factory entry + import)
- Modify: `cmd/server/command_menu_test.go` (`expectedParameters`, +9)
- Modify: `README.md`
- Create: `docs/sticker-packs.md`
## Implementation Steps
1. Set `MODULES` explicitly in the deployed environment; verify an unchanged command set.
2. Draft the nine descriptions against the ~98-rune budget; measure `RenderHelp` locally.
3. Add the factory entry and import.
4. Update `expectedParameters`; run `go test ./cmd/server/...`.
5. README row + section; `docs/sticker-packs.md`.
6. Full gate, then the manual smoke sequence.
## Todo
- [ ] Set `MODULES` explicitly in the deployed environment and verify
- [x] Update `.env.example` with the explicit list and a why-comment
- [x] Confirm `mypack` is free in the registry alongside the other eight
- [x] Draft nine descriptions within the measured budget and re-measure `RenderHelp`
- [x] Add `"sticker": sticker.New` to `factories()`
- [x] Add nine entries to `expectedParameters`
- [x] Add the `<name...>` row + example to `docs/command-parameter-conventions.md`
- [x] README module-table row + `### Sticker packs` section
- [x] `docs/sticker-packs.md`
- [x] Full validation gate
- [ ] Manual smoke sequence against a real token
## Success Criteria
- [x] `gofmt -l .` empty; `go vet ./...` clean; `go test ./...` passes; `golangci-lint run` clean
- [x] `RenderHelp` stays under 4096 runes with all nine commands registered
- [ ] With `MODULES` unset in a scratch environment, the module still loads — confirming C10 is understood rather than assumed away
- [ ] With the explicit `MODULES` list and no `sticker` entry, none of the nine commands register
- [ ] Smoke: reply to a sticker with `/newpack smoke_pack Smoke Pack`; the returned link opens
- [ ] Smoke: a second `/newpack` is refused and names the existing pack
- [ ] Smoke: reply to a photo with `/addsticker 😂` — no pack named — and the sticker renders undistorted
- [ ] Smoke: `/mypack`, `/renamepack New Title`, `/setpackicon`, `/ordersticker 0` all succeed
- [ ] Smoke: `/renamepack` leaves the share link working and unchanged
- [ ] Smoke: a **second** account replying to the first account's sticker with `/delsticker` is refused
- [ ] Smoke: an anonymous group admin is refused with the explanatory message
- [ ] Smoke: `/delpack` confirm prompt shows the title, sticker count, and link before confirming
- [ ] Smoke: `/delpack` → confirm → link 404s; a second press reports already-used; `/newpack` then works again
- [ ] Smoke: **after deleting, attempt `/newpack` with the same slug** — this settles plan R11. Record the outcome in `docs/sticker-packs.md` either way, and if the slug is reserved, add that to `/delpack`'s confirm text
- [ ] Smoke: `/renamepack` reply names the delete-and-recreate route
- [ ] Smoke: observed error strings for an occupied slug and a full pack match `replyAPIError`, or the table is corrected
## Risk Assessment
**The error-code table is unverified until this phase.** Only three MTProto codes are rewritten
into prose by the Bot API server; the rest were inferred from the open-source server's rewrite
table, not a live reproduction (plan R3). The smoke sequence deliberately includes an
occupied-slug and a full-pack case. A mismatch is a polish gap, not a blocker — the generic
fallback means users see a sane message either way.
**Manual smoke is the only Telegram-side coverage.** Every automated test uses `RecordingBot`,
which never contacts Telegram. CI cannot catch a wrong parameter name or a rejected image
format. The smoke sequence is mandatory before announcing the feature.
**Enablement ordering is a process risk, not a code one.** If step 1 is skipped and the factory
entry merges first, the module goes live unannounced. Signal: the deployed bot answers
`/mypack` before anyone enabled it. Response: set `MODULES` immediately; the module is otherwise
harmless until someone runs a command.
@@ -1,588 +0,0 @@
---
title: "Sticker packs module"
description: "internal/modules/sticker — public, one-pack-per-user Telegram sticker set management via single-shot reply commands using @Stickers command names"
status: partial
priority: P2
effort: ""
tags: ["sticker", "telegram-bot", "module"]
created: 2026-08-24
branch: main
blockedBy: []
blocks: []
---
# Sticker packs module
## Overview
New module `internal/modules/sticker` letting **any** user create and manage **one**
personal Telegram sticker pack through the bot. The pack is created on behalf of the calling
user (`user_id`), named `<slug>_by_<bot_username>`, and stays bot-manageable because the bot
created it.
One pack per user is the central simplification: no command except `/newpack` takes a pack
argument, because there is only ever one pack to act on.
Command names mirror @Stickers (`/newpack`, `/addsticker`, …) but each command is
**single-shot**: one message carrying its arguments, optionally replying to a sticker or
photo. Commands are **unprefixed**, like the `misc` module (`/ff`, `/random`).
Phase 1 fixes two shared-code gaps this module would otherwise expose. They are
prerequisites, not incidental work.
## Goals
| # | Goal | Priority |
|---|------|----------|
| 1 | Any user can create and fill their personal sticker pack without leaving the chat | P1 |
| 2 | No command but `/newpack` requires naming a pack | P1 |
| 3 | Ownership is enforced structurally — a user can never mutate another's pack | P1 |
| 4 | No sticker command can stall the bot for other users beyond a bounded deadline | P1 |
| 5 | A partial failure never permanently strands a user's pack | P1 |
| 6 | Command names and semantics recognisable to @Stickers users | P2 |
| 7 | Enabling the module is an explicit operator decision | P2 |
## Accepted scope
| Decision | Value |
|---|---|
| Visibility | `VisibilityPublic` — every user manages their own packs |
| Pack model | **One pack per user.** The slug is chosen at creation and never used as an argument again |
| Inputs | Existing static stickers (reply) + photos/image documents (reply) |
| Interaction | Single-shot reply + args; no conversation state, no `/cancel` |
| Naming | @Stickers command names, no module prefix |
## Command surface
All `VisibilityPublic`. `Parameters` follows `docs/command-parameter-conventions.md`.
| Command | Parameters | Reply required | API calls |
|---|---|---|---|
| `/newpack` | `<pack> <title...>` | yes (sticker/photo) | `GetStickerSet`, `UploadStickerFile`*, `CreateNewStickerSet` |
| `/addsticker` | `[emoji...]` | yes (sticker/photo) | `UploadStickerFile`*, `AddStickerToSet` |
| `/delsticker` | — | yes (sticker in own pack) | `DeleteStickerFromSet` |
| `/editsticker` | `<emoji...>` | yes (sticker in own pack) | `SetStickerEmojiList` |
| `/ordersticker` | `<position>` | yes (sticker in own pack) | `SetStickerPositionInSet` |
| `/setpackicon` | — | yes (sticker in own pack) | `GetFile`, `SetStickerSetThumbnail` |
| `/renamepack` | `<title...>` | no | `SetStickerSetTitle` |
| `/delpack` | — | no (inline confirm) | `DeleteStickerSet` |
| `/mypack` | — | no | **none** — count comes from the store |
`*` only on the photo path.
### Slug is a name, not an address
`<pack>` survives on `/newpack` alone, where it fixes the permanent share URL
`t.me/addstickers/<slug>_by_<bot>`. Telegram has no rename-short-name method, so that choice
is unfixable afterwards — which is exactly why it stays user-chosen rather than derived from
a user ID (which would publish the owner's numeric Telegram ID forever) or generated opaquely.
Every other command resolves the caller's single pack from the store, or from the replied
sticker's `set_name` matched against it. This supersedes the earlier multi-pack design in
which `<pack>` was an argument to `/addsticker`, `/renamepack`, and `/delpack`.
### Dropped from @Stickers
`/stats`, `/top`, `/packstats`, `/packtop`, `/topbypack`, `/packusagetop` report sticker
**usage counts**, which the Bot API does not expose. `/stats` is also already owned by the
`stats` module. `/newanimated`, `/newvideo`, `/newemojipack`, `/newmasks` are outside the
accepted static-only scope. `/cancel` is meaningless without conversation state.
## Architecture constraints (verified against this repo and the live API)
Each was checked against source, not assumed. C1–C8 survived adversarial review; C5 was
corrected.
### C1 — Handlers are globally serialized
`internal/telegram/client.go:27` passes `bot.WithNotAsyncHandlers()`, and the library's
`defaultWorkers = 1` (`bot.go:20`) with no `WithWorkers` override. `process_update.go:26-28`
runs the handler inline. One slow handler stalls **every** user.
Caveat found in review: "one update at a time" is not "one goroutine". The cron scheduler
(`cmd/server/main.go:163`) and the detached per-command stats hook
(`internal/modules/dispatcher.go:80-84`) both run concurrently with handlers. Neither
touches pack state today, but the per-user keylock is therefore **not** redundant.
### C2 — No per-update deadline, and the library's own ceiling is 60s
Handler ctx is `rootCtx` (`cmd/server/main.go:107,214`), which has no deadline, so
`chathelper.FetchContext` (`chathelper.go:107-113`) returns a bare `WithCancel` that bounds
nothing. The only remaining ceiling is the library's shared
`http.Client{Timeout: time.Minute}` (`bot.go:17-18,75-77`).
Consequence: **every** handler needs its own explicit deadline, not just the photo path.
Ten sequential API calls under a 60s per-call ceiling is a ~10-minute bot-wide freeze.
### C3 — Callback data caps at 64 bytes
`internal/modules/stock/pending_dividend.go:16-17` enforces `maxDividendCallbackBytes = 64`
against Telegram's limit. `/delpack` carries an opaque pending-action id, not a slug, so the
budget is comfortable.
### C4 — Callback prefix conflicts are checked bidirectionally
`internal/modules/registry.go:216-219`. `sticker_pack:` does not overlap `stock_div:`, the
only existing prefix.
### C5 — The API cannot prove ownership, so the bot must record intent before acting
`getStickerSet` returns only `name`, `title`, `sticker_type`, `stickers`, `thumbnail`
(`models/sticker_set.go:4-10`) — no owner field.
The earlier draft concluded "therefore orphaned sets can never be adopted". Review showed
that does not follow: the bot does not need the API to name the owner, it needs **its own
record of who asked for that name**. Since only this bot can create `*_by_<bot_username>`
sets, a write-ahead intent record makes an existing set attributable. See R2 and Phase 3.
### C6 — Raw bytes cannot ride on `AddStickerToSet`
`build_request_form.go:105` handles `attach://` only for `[]models.InputSticker`. The single
`InputSticker` in `AddStickerToSetParams` (`methods_params.go:905-909`) falls through to
`addFormFieldDefault`, and `StickerAttachment` is `json:"-"` (`models/sticker.go:40`), so it
is silently dropped. Photo path must be `UploadStickerFile` (which accepts
`*models.InputFileUpload`, `build_request_form.go:87`) → use the returned `File.FileID`.
### C7 — Library is current for stickers
Bot API is at 10.3 (2026-08-24); last sticker changes were Bot API 7.2 (2024-03-31).
`v1.20.0` has `InputSticker.Format`, no top-level `sticker_format` on
`CreateNewStickerSetParams`, and `ReplaceStickerInSet`. No known gap.
### C8 — Telegram limits (confirmed against official docs)
| Limit | Value |
|---|---|
| Set name | 1–64 chars, letters/digits/underscore, begins with a letter, no consecutive underscores, ends `_by_<bot_username>` (case-insensitive) |
| Static sticker image | PNG or WEBP; one side **exactly** 512px, other ≤512px |
| Static sticker file size | **Not documented.** The widely-repeated 512 KB figure appears in no current official page (R4) |
| Set thumbnail | PNG/WEBP, exactly 100×100, ≤128 KB |
| Stickers per set | 120 regular/mask, 200 custom emoji |
| Emoji per sticker | 1–20 |
| Set title | 1–64 chars |
| Sets per bot | **Not documented** — no known ceiling |
### C9 — There is no panic barrier on the update path
`rg "recover()"` finds four sites: `testutil/mongotest`, `server/log_middleware.go:50`,
`monkeyd/export_job.go:52`, `cron/scheduler.go:67`. **None on the command or callback path.**
`internal/modules/dispatcher.go:167` carries a stale comment promising "our `recover()` in
webhook.go"; `internal/telegram/webhook.go` contains only `DeleteWebhook`.
With C1, a panic in any handler terminates the process. Phase 1 closes this.
### C10 — `MODULES` is not opt-in
`internal/modules/registry.go:107-116` expands an empty list to every registered factory,
and its own comment calls that the documented contract (`.env.example:16` ships `MODULES=`
empty; `compose.yml:14` says "empty = all modules"). Adding a `factories()` entry **is** the
enablement. Phase 6 sets `MODULES` explicitly before merging.
### C11 — `RecordingBot` cannot return structured results
`internal/testutil/recording_bot.go:178-195` answers every non-message-producing method with
`{"ok":true,"result":true}`. `GetStickerSet`, `GetFile`, and `UploadStickerFile` decode into
structs, so under the current harness they can only ever **error**. `FailMethod`
(`:99-112`) emits no `error_code`, so library errors in tests never take the
`ErrorBadRequest` shape production emits. Phase 1 extends the harness.
## Phases
| # | Phase | Status |
|---|-------|--------|
| 1 | [Phase 1: Shared prerequisites](./phase-01-shared-prerequisites.md) | Done |
| 2 | [Phase 2: Store, set names, emoji parsing](./phase-02-store-setname-emoji.md) | Done |
| 3 | [Phase 3: Pack lifecycle commands](./phase-03-pack-lifecycle.md) | Done |
| 4 | [Phase 4: Sticker commands (reply path)](./phase-04-sticker-commands.md) | Done |
| 5 | [Phase 5: Photo pipeline and pack icon](./phase-05-photo-pipeline.md) | Done |
| 6 | [Phase 6: Wiring, menu, docs](./phase-06-wiring-docs.md) | Partial — code + docs done; live smoke and deployed MODULES pending |
## Dependencies
Phase 1 blocks everything (shared code + test harness). Phase 2 blocks 3, 4, 5. Phase 3
blocks 4 and 5. Phase 5 depends on Phase 4's `/addsticker` handler. Phase 6 last. No
cross-plan dependencies — the only other plan is completed and touches disjoint files.
## Cross-cutting rules
These apply to every handler in Phases 3–5. Stated once here rather than repeated.
1. **Explicit deadline.** Every handler opens with
`ctx, cancel := context.WithTimeout(ctx, handlerTimeout)` (`handlerTimeout = 10s`,
package constant). Required by C2 — nothing else bounds a call.
2. **Durable writes survive shutdown.** Store writes that commit a completed Telegram-side
action use `context.WithoutCancel(ctx)` plus a short timeout, mirroring the existing
idiom at `dispatcher.go:78-84`. `rootCtx` is cancelled by SIGTERM mid-handler, so a
plain `ctx` write fails on every deploy (R2).
3. **One sender helper.** `senderID(msg) (int64, error)` rejects a nil `From`, `From.IsBot`,
and any message carrying `SenderChat`. Anonymous group admins share a single
`GroupAnonymousBot` id, so without this all anonymous admins across all groups share one
pack namespace and one quota (R6).
4. **Positive error classification only.** `isStickerSetMissing(err)` is
`errors.Is(err, bot.ErrorBadRequest) && strings.Contains(err.Error(), "STICKERSET_INVALID")`.
Any other error is "unknown — abort with no side effects". Never infer "absent" from a
generic failure (R3, R7).
5. **Errors from the download path never escape raw.** They are converted to a sentinel at
the boundary, discarding the original (R5).
## New dependency
`golang.org/x/image` — for `draw.CatmullRom` resampling. Stdlib decodes JPEG/PNG and encodes
PNG but ships no scaler, and stickers need an exact 512px long edge. Note it becomes the
repo's first *direct* `golang.org/x/*` requirement; all current ones are indirect.
## Abuse surface
Public module creating durable Telegram-side objects on a single-threaded dispatcher (C1):
- One pack per user, enforced by a create-only write before `CreateNewStickerSet`. This is
the quota; there is no separate counter to keep.
- `handlerTimeout = 10s` on every handler — the primary bound (C2).
- `/mypack` makes **zero** API calls; the count lives on the `Pack` record. A single `Get`,
no `List`, no per-pack fan-out.
- Photo source rejected above 2 MB; decoded dimensions capped at 4096×4096.
- Slug alphabet `^[a-z][a-z0-9_]{2,39}$`, no `__`, no trailing `_`.
- `internal/keylock` per-user serialization — genuinely load-bearing, not decorative,
because crons and the detached stats hook run concurrently with handlers (C1 caveat).
- Anonymous/bot senders refused outright (cross-cutting rule 3).
## Risks
| # | Risk | Signal it broke | Response |
|---|---|---|---|
| R1 | A handler stalls the bot for all users (C1) | Reply latency spikes for unrelated commands | `handlerTimeout` caps it at 10s; if still felt, lower it, then consider offloading the photo path (Phase 5 R1) |
| R11 | A deleted slug may not be reclaimable | `/newpack <old-slug>` after `/delpack` reports the slug taken | Unknown at decision time; official docs are silent and community reports lean toward short names staying reserved. Does **not** block the URL-change path, which needs a *different* name. Settled empirically in Phase 6 smoke; if reserved, say so in `/delpack`'s confirm text |
| R10 | A user wants two packs and cannot have one | Requests for a second pack | Accepted by design. Reversing it means restoring `<pack>` arguments across four commands — a deliberate, not incidental, change |
| R2 | Partial failure strands a pack | User reports a slug reported taken that `/mypack` does not show | Write-ahead intent + `WithoutCancel` commits (Phase 3). Reverse gaps for `/delpack` and `/renamepack` documented in Phase 3 |
| R3 | Error-code matching drifts | Users see the generic reply where a specific one was expected | Match MTProto **codes**, never human text; confirm empirically in Phase 6 smoke |
| R4 | The 512 KB static-sticker ceiling may not be real | Uploads succeed above it, or fail below it | Client-side ceiling only; never stated as spec in user-facing text |
| R5 | Bot token leaks through a transport error | Any log line containing `api.telegram.org/file/bot` | Sentinel conversion at the download boundary + a test asserting the error text is clean (Phase 5) |
| R6 | Ownership collapses for anonymous senders | Two users see each other's packs | Cross-cutting rule 3 refuses them before any store access |
| R7 | A transient error deletes a live pack's record | `/mypack` reports no pack though the user can still open theirs via link | Rule 4 — destructive store deletes require positive `STICKERSET_INVALID` |
| R8 | Bot username changes in BotFather | Every pack refuses as "not yours" after restart | Ownership matches stored `Pack.Name` against `Sticker.SetName`; username only builds *new* names (Phase 2) |
| R9 | `golang.org/x/image` resampling disappoints | Visibly soft or aliased stickers in smoke | Swap `CatmullRom` for `ApproxBiLinear`; one line, one file |
| R12 | `AddStickerToSet` may reject a `file_id` from a set this bot did not create | `/addsticker` on a sticker from any other pack fails while the photo path works | The one API assumption in this plan never checked against the live API — the docs allow a `file_id` in `InputSticker.sticker` but are silent on provenance. Settle with one live call before writing Phase 4's handler. If rejected, `/addsticker`'s sticker path routes through Phase 5's `GetFile` → download → `UploadStickerFile`, which makes Phase 4 depend on Phase 5 rather than the reverse — cheap to know first, expensive to discover after |
## Accepted disclosure
`/newpack` answers "is this slug taken?" for any slug, which reveals that *some* user of this
bot owns it. This is accepted, not solved: `t.me/addstickers/<slug>_by_<bot>` is publicly
probeable without the bot, so the command adds no information an attacker lacks. The earlier
draft claimed no disclosure while shipping this probe — the claim was wrong and is removed.
Pack *management* refusals remain deliberately uniform (Phase 4), because those would
otherwise disclose which of the caller's own guesses correspond to real packs.
## Success Criteria
- [ ] A non-admin user can reply to a sticker with `/newpack mypack My Pack` and receive a working `t.me/addstickers/mypack_by_<bot>` link
- [ ] `/addsticker 😂` on a photo produces a valid static sticker with a 512px long edge and correct aspect ratio, with no pack named
- [ ] Managing a pack the caller does not own fails with an ownership error and makes zero API calls
- [ ] The not-owned reply text is byte-identical whether the set is another user's or another bot's
- [ ] `/mypack` shows slug, title, count, and link for the caller's own pack, making no API calls
- [ ] A second `/newpack` while a pack exists is refused, naming the existing pack and pointing at `/delpack`
- [ ] `/delpack` takes no argument and requires inline confirmation bound to invoker, chat, message, and a TTL
- [ ] `/delpack`'s confirm prompt states the title, sticker count, link, and permanence before the tap
- [ ] `/renamepack`'s reply names the `/delpack` + `/newpack` route to a different URL
- [ ] Every handler is bounded by an explicit deadline; no handler can exceed `handlerTimeout`
- [ ] A panic in any module handler is contained and logged, and does not terminate the process
- [ ] An interrupted `/newpack` can be completed by re-running the same command
- [ ] Anonymous group admins and bot senders are refused before any store or API access
- [ ] No log line or error string contains the file-download URL
- [ ] Module is enabled only by an explicit `MODULES` entry, verified in a deployed environment
- [ ] `go test ./...`, `go vet ./...`, `gofmt -l .`, and `golangci-lint run` all clean
## Open Questions
None. Both prior questions were resolved by the one-pack-per-user revision: `/editsticker`
takes no pack argument because no command but `/newpack` does (former O1), and the
per-user pack limit is one (former O2).
## Design Revisions
### 2026-08-25 — /delpack confirmations must prove authority, not disprove it
Removing adoption closed the takeover class, but the same DeleteStickerSet
primitive stayed reachable through a stale confirmation. The under-lock re-check
was written as a blocklist — it refused only a *pending* record still naming the
set — and fell through on the two states that mattered: no record at all, and a
record that had moved on. Reproduced with ordinary commands and no attacker:
prompt, pack disappears at Telegram, self-heal frees the name, another user
claims it, first user presses, their pack is destroyed.
Inverted to an allowlist: delete only when a confirmed record still names this
exact set. A guard phrased as "which states do I refuse" cannot fail closed
against a state nobody enumerated. Dropping a pack record also clears any
outstanding confirmation, so a dead prompt is gone rather than merely refused.
This also closes the reservation leak on the confirmed-delete path, since the
moved-on case no longer reaches Telegram at all.
Fixed alongside: resuming an interrupted `/newpack` silently discarded a retyped
title and reported success with the old one; a test named for freeing a dead
name never asserted it; and `lockUser`'s comment justified the lock with cron
and stats-hook contention that does not exist — the map is state-local and this
module registers neither.
### 2026-08-25 — pack adoption removed
Verification found the round-4 guard defeated by the module's own recovery
branch: an inconclusive `GetStickerSet` keeps the reservation, which turns a
*fresh* claim into a *resumed* one, so two ordinary `/newpack` commands took
over a stranger's pack. `resolveStaleIntent` had a second adopt path that never
consulted the guard at all. Reproduced against the real handlers.
That is the fourth consecutive failure of the same mechanism, and the reason is
structural rather than a bug that can be patched. Adoption needs to prove "this
set is mine to finish" from local state, and local state is exactly what a
restart on the in-memory backend erases while the packs at Telegram survive.
Once the proof is gone the honest and the malicious case are indistinguishable.
Adoption is therefore removed entirely — both branches. `/newpack` refuses any
name a set already occupies, for everyone, whatever the records say.
Implementing it surfaced a second hole the option did not cover: a *pending*
record is not evidence either, and `/delpack` deletes by set name, which
Telegram authorises for every set this bot created. Keeping a refused intent so
the user could `/delpack` it would hand over a way to destroy the pack we had
just refused to adopt. `/delpack` now clears a pending record locally and calls
Telegram only for a confirmed one.
Accepted cost: a crash between creating a set and recording it strands that set
permanently. Documented rather than mitigated, because every mitigation is the
mechanism that just failed four times.
### 2026-08-25 — three-lens review pass (security, correctness, tests)
Three independent reviewers ran against the finished module. What they changed:
- **Adoption no longer trusts the reservation alone.** The reservation proves
ownership only while it outlives the sets it guards, and it does not: it lives
in our store, the packs live at Telegram, and the in-memory backend is
selected silently whenever `MONGO_URL` is unset. After any wipe the original
takeover was reachable again. `createOrAdopt` now refuses to adopt when *this*
invocation first claimed the name — a genuine interrupted attempt always finds
its own reservation waiting, so there are no false negatives.
- **Post-action cleanup reads moved onto the detached context.** They wrote via
`commitContext` but read on the request context, so a cancelled request failed
the read and skipped the release while still deleting the pack record —
stranding a name permanently. Found independently by two reviewers.
- **Emoji clustering.** Nine valid emoji were refused outright; tag-sequence
flags shattered; and three inputs (trailing ZWJ, a joiner before a flag, an
odd regional-indicator count) passed validation and would have sent an
`emoji_list` Telegram rejects.
- **A reply tail is now reserved** from the handler budget via the existing
`chathelper.FetchContext`, which every other data-fetching module already
used. A slow photo `/newpack` could spend the whole 10s before Telegram was
called, and then send its error reply on a dead context — the user saw nothing.
- **The compression ladder resamples the scaled image, not the source.** It was
paying a full-size resample per rung: measured 1.99s versus 74ms for the three
rungs, on a dispatcher that runs handlers one at a time.
- **Tests.** Mutation testing refuted the previous round's non-vacuity claim in
three places. The `created`-flag release machinery had no coverage in either
direction; two emoji assertions passed against a handler that never called
Telegram; and nothing pinned module registration — the whole module could be
removed from `factories()` with the suite still green.
Left open deliberately: the shutdown path never joins the polling goroutine, so
`context.WithoutCancel` does not actually survive process exit. That is
`cmd/server/main.go`, outside this module and affecting every module's commits.
### 2026-08-25 — one pack per user
The accepted scope originally chose "named packs, multiple per user, addressed by slug
argument". The user revised it to one pack per user, with every command operating on that
pack implicitly.
Removed by the revision:
- `<pack>` arguments on `/addsticker`, `/renamepack`, and `/delpack`
- `/packlist` (replaced by `/mypack`, singular) and its `List` + per-pack `Get` fan-out
- `maxPacksPerUser` as a tunable — the limit is one
- The separate default-pack command and per-user prefs record that a multi-pack default
would have required
Red-team findings this revision resolves outright rather than mitigates:
- Finding 4 (`/packlist` unbounded: 60s client timeout x 10 calls plus an N+1) — `/mypack`
is a single `Get` and makes no API calls at all
- Former open question O2 (`maxPacksPerUser` sizing) — no longer a choice
Unaffected: Phase 1 (panic barrier, test harness) and Phase 5 (photo pipeline, token-leak
sentinel) need no changes. The write-ahead intent machinery, anonymous-sender rule,
`handlerTimeout`, and bot-rename resilience all carry over unchanged.
Trade-off accepted: a user who wants a memes pack and a reactions pack separately cannot
have both (R10).
### 2026-08-25 — `/delpack` as the URL-change path
`/delpack` was reviewed as a destructive convenience. It is in fact the **only** mechanism for
changing a pack's URL, because Telegram exposes no rename-short-name method. That reframing
changed three things without adding a command:
- `/renamepack`'s reply now names the delete-and-recreate route instead of only stating that
the link cannot change — turning a dead end into an answer.
- `/delpack`'s confirm prompt must state the pack title, the sticker count being destroyed, the
exact link being surrendered, and that both are permanent. It is the last point at which a
user learns stickers do not survive a URL change.
- A `/repack <newslug>` migration command was considered and **rejected for this plan**: copying
a full pack is up to ~121 sequential API calls, which blows `handlerTimeout` and stalls the
bot for every user under C1. It is viable only after Phase 5's goroutine offload lands, and is
recorded here as a follow-up rather than scoped in.
The @Stickers command set was mapped exhaustively against the one-pack model; all nine of our
commands are either direct adaptations or (for `/mypack`) a justified addition, and every
dropped @Stickers command has a stated reason.
**Bug found during this pass** (Phase 3, `/newpack`): the different-slug pending branch
overwrote the pending record unconditionally, which permanently orphans a set that was created
before an interruption. It now probes `GetStickerSet(oldName)` first and adopts rather than
overwrites when the old set exists.
### 2026-08-25 — post-implementation review: global slug reservation
An independent review of the implemented module found a **pack takeover** in
`/newpack`, and it traces back to this plan, not only to the code.
Phase 3 step 5 said: `GetStickerSet` succeeds → "we hold a `Pending` record for
this owner and slug, so this is our own interrupted attempt: adopt it". The
plan's own risk section stated the supporting invariant as *"a `Pending` record
for an owner means that owner asked for that name"*. That is true and **not
sufficient**. Asking is not creating. `PutVersioned` is create-only per *owner
key* (`packKey` is the owner ID alone); nothing reserved a name globally. So a
user with no pack who typed another user's slug produced byte-identical evidence
to a genuine resumed attempt — and adopted their pack, then could `/delpack` it.
Every share link is public, so slugs are trivially enumerable.
The plan pre-authorised one response to this signal ("disable adoption — a
one-line change"), which would have closed the hole by dropping the
"an interrupted `/newpack` can be completed by re-running" success criterion.
The user chose the stronger fix instead:
**A global slug reservation.** `SlugReservation{Slug, OwnerID}` is written
create-only under `slug:<slug>` *before* Telegram is touched. Adoption is
allowed only when the reservation names the caller. The first claimant of a name
is the only user who can ever adopt a set under it, so "this set is mine" became
a proven fact rather than an assumption — and both success criteria survive.
Consequent changes:
- `reserveSlug` runs before `claimSlug`; a name held by anyone else replies
"that pack name is taken" with **zero** API calls.
- `resolveStaleIntent` re-proves the *old* slug's reservation before adopting
under it, and releases it on a positive "no such set".
- `dropIntent` no longer fires on unknown errors (was a plan rule 4 violation in
its own right): on anything but a *classified* refusal both the intent and the
reservation survive, which is what lets a re-run recover. Only a positive
refusal releases them.
- `/delpack` keeps the reservation, so a deleted name stays recoverable by its
owner and unavailable to everyone else — which also matches Telegram's likely
behaviour for deleted short names (R11).
Four further defects fixed in the same pass:
- **A stale `/delpack` confirmation deleted a live pack's record.** Pending
actions used a random key per invocation, so two could be live at once, and
the delete path cleared the record *by owner* without checking it still named
the set being deleted — precisely the documented delete-then-recreate URL
change. Now keyed per user (matching `stock/pending_dividend.go`), superseded
by a newer prompt, and gated on `ownsSet` before clearing.
- **Unbounded storage from a public command.** The same random key meant a user
who ran `/delpack` and never tapped left a permanent document.
- **The panic barrier missed the detached hook goroutine**, so a panicking
`CommandHook` (the `stats` module ships one) still killed the process. C9's
"Phase 1 closes this" was true for handlers only.
- `/delpack`'s result message bypassed `chathelper.Reply`, so in a forum
supergroup it landed in General instead of the topic.
### 2026-08-25 — Phase 4 review pass
Ten findings applied before implementation began. Six changed what gets written:
- `resolveSource` takes `(ctx, b, ownerID, msg)` from the start, and `photoRef` is gone — the
photo branch resolves to a `file_id` like every other path, so Phase 5 adds a branch instead
of rewriting call sites.
- The static-only gate moves onto `resolveSource`, the path that can actually receive a
non-static sticker, and now also rejects `Type != "regular"` — a mask sticker is static yet
invalid for a regular set, which `IsAnimated || IsVideo` does not catch.
- `/addsticker` gained its missing no-pack path, and the note on why its refusal is
deliberately *not* the uniform `resolveOwned` one.
- `/delsticker` takes the same per-user keylock as `/addsticker`; both do the same
read-modify-write on `Count`.
- `/delsticker`'s "never deletes the record" criterion was scoped to transient and unknown
errors. Unqualified, it forbade Phase 3's `STICKERSET_INVALID` self-heal — the very
mechanism that unwedges a user whose set Telegram removed.
- The animated/video check moved ahead of the store read in `resolveOwned`'s numbered gate.
Also: the `Count: 0` aftermath now names its recovery route, `STICKERS_TOO_MUCH` and the
no-pack paths gained success criteria, R12 records the unverified `file_id`-reuse premise, and
Phase 6 must document `<name...>` in the conventions doc rather than shipping an undocumented
parameter form.
#### Consistency sweep — one-pack revision
- Files reread: plan.md and all six phase files.
- Deltas checked: 9 (`<pack>` dropped from `/addsticker`, `/renamepack`, `/delpack`;
`/packlist` -> `/mypack`; `listPacks` -> `getPack`; key `<ownerID>:<slug>` -> `<ownerID>`;
`maxPacksPerUser` removed; `matchPack` -> `ownsSet`; `buildSetName` -> `makeSetName`).
- Stale references reconciled: 4 (incl. the frontmatter description, which said
"multi-pack-per-user" and is surfaced by every `ak plan list`). Two live risk signals in the R2/R7 rows still named
`/packlist`; Phase 5's `/setpackicon` still said "one of the caller's packs". Phase 5 was
therefore **not** untouched, contrary to the initial assessment.
- Remaining `/packlist`, `listPacks`, and `maxPacksPerUser` mentions are all deliberate
comparative or historical text ("this replaced X", the revision log, the red-team table).
- Phases 1 and 5 confirmed free of multi-pack phrasing after the fix.
- Unresolved contradictions: 0
## Red Team Review
### Session — 2026-08-25
**Findings:** 28 raw from 3 reviewers → 16 after dedup (16 accepted, 0 rejected)
**Severity breakdown:** 7 Critical, 6 High, 3 Medium
**Reviewers:** Security Adversary (Fact Checker), Failure Mode Analyst (Flow Tracer),
Assumption Destroyer (Scope Auditor). All findings carried `file:line` evidence, so none
were filtered. Four of the seven Critical findings were independently corroborated by all
three reviewers.
| # | Finding | Severity | Disposition | Applied To |
|---|---------|----------|-------------|------------|
| 1 | Empty `MODULES` loads all modules; "opt-in" false in 3 places | Critical | Accept | C10, Phase 6, Goals |
| 2 | No `recover()` on the update path; a handler panic kills the process | Critical | Accept | C9, Phase 1 |
| 3 | Bot token leaks via `*url.Error` into the dispatcher log | Critical | Accept | Rule 5, R5, Phase 5 |
| 4 | `/packlist` unbounded: 60s client timeout × 10 calls + N+1 | Critical | Accept | C2, Rule 1, Phase 3 |
| 5 | `/delsticker` probe deletes the record on any error | Critical | Accept | Rule 4, R7, Phase 4 |
| 6 | `/delpack` button is a permanent replayable delete capability | Critical | Accept | Phase 3 |
| 7 | `RecordingBot` cannot return structs; 3 phases untestable | Critical | Accept | C11, Phase 1 |
| 8 | Anonymous group admins share one `From.ID` | High | Accept | Rule 3, R6 |
| 9 | Bot rename orphans every pack; `Pack.Name` written never read | High | Accept | R8, Phase 2, Phase 4 |
| 10 | `GetStickerSet` not-found is an undefined branch | High | Accept | Rule 4, Phase 3 |
| 11 | Commit writes on `rootCtx`; deploy is the normal orphan path | High | Accept | Rule 2, R2, Phase 3 |
| 12 | `/help` 4096-rune ceiling; 884 runes headroom measured | High | Accept | Phase 6 |
| 13 | C5's no-adoption conclusion not forced by its premise | High | Accept | C5, R2, Phase 3 |
| 14 | `Put` used where create-only `PutVersioned` exists | Medium | Accept | Phase 3 |
| 15 | `parseSlug` case-preserving value reaches the storage key | Medium | Accept | Folded into #9 |
| 16 | `/newpack` oracle contradicts the plan's own no-disclosure claim | Medium | Accept | Accepted disclosure section |
**User decisions taken during adjudication:** explicit `MODULES` before Phase 6; panic
barrier in `modules.Install` as Phase 1; module-wide deadline plus persisted counts;
write-ahead intent record for orphan recovery.
### Whole-Plan Consistency Sweep
- Files reread: plan.md, phase-01-shared-prerequisites.md, phase-02-store-setname-emoji.md,
phase-03-pack-lifecycle.md, phase-04-sticker-commands.md, phase-05-photo-pipeline.md,
phase-06-wiring-docs.md
- Decision deltas checked: 14 (phase renumber 1-5 -> 1-6; `parseSlug` -> `matchPack`;
`/delsticker` probe removed; `/packlist` API-free via `Pack.Count`; `Pack.Pending`
write-ahead intent; callback payload slug -> opaque id; C5 rewritten; opt-in claims
removed; `handlerTimeout` cross-cutting rule; `senderID` rule; `isStickerSetMissing`;
download sentinel; slug cap decoupled from C3; `Put` -> `PutVersioned` for create)
- Reconciled stale references: 0 remaining. `parseSlug` survives only in phase-02 as the
named superseded design and in the red-team table as finding #15 — both deliberate
historical references, not live claims. "opt-in" survives only in C10's negative heading
and the finding that corrected it.
- Link integrity: all 6 phase links resolve; no orphan phase files.
- Cross-phase references verified consistent under the new numbering.
- Unresolved contradictions: 0
<!-- slug: sticker-pack-module -->
@@ -1,196 +0,0 @@
---
phase: 1
title: "Phase 1: Scope keys, normalization, matcher"
status: done
priority: P1
effort: "3h"
dependencies: []
---
# Phase 1: Scope keys, normalization, matcher
## Overview
The whole decision core of the module, with no Telegram and no storage in it. Three pure
files that a test can drive directly: how a thread becomes a key prefix, how arbitrary user
text becomes a comparable and storable form, and how a text is judged against two lists.
Every correctness risk in the module lives here. Isolating it means the matcher's behaviour
is settled and tested before a single handler exists.
## Requirements
- Functional: a `(chatID, threadID, list)` triple maps to a unique, collision-free key
prefix, and an entry's normalized text maps to a key under it that survives
`internal/storage/keys.go` validation.
- Functional: normalization composes Unicode, folds case and collapses whitespace, and
leaves combining diacritics intact.
- Functional: the matcher reports whether a text is blocked, which blacklist entry decided
it, and which whitelist entry rescued it when one did.
- Non-functional: no dependency on `go-telegram/bot` or `internal/storage` in any of the
three files, so the package's core is unit-testable in isolation.
- Non-functional: deterministic output for the same inputs regardless of slice order.
## Architecture
### `internal/modules/blacklist/scope.go`
A thread is `(Chat.ID, MessageThreadID)`, the same pair `lol` already uses for subscriptions
(`internal/modules/lol/subscribers.go:14-24`). `MessageThreadID == 0` means a non-forum
group, a forum's General topic, or a DM — a real scope, not a missing value.
```go
const (
listBlack = "b"
listWhite = "w"
)
// scopePrefix is the key prefix for one list of one thread. Both numbers are
// decimal and the list tag is a single letter, so the third ':' always ends the
// prefix even though entry text may contain ':' of its own.
func scopePrefix(chatID int64, threadID int, list string) string
// entryKey names one entry. normText must already be normalized.
func entryKey(chatID int64, threadID int, list, normText string) string
```
Key shape: `<chatID>:<threadID>:<b|w>:<encoded text>`. Group chat IDs are negative, which
costs a leading `-` and nothing else.
`internal/storage/keys.go:24-39` forbids `/`, the exact strings `.` and `..`, the
`__namespace__` pattern, and keys over 1500 bytes. The numeric prefix makes the middle three
unreachable by construction. `/` and the length cap are handled explicitly:
```go
// encodeKeyText escapes the two characters that cannot appear literally in a
// key. '%' is escaped first so the escape marker itself stays unambiguous —
// reversing the order would make an entry containing "%2F" decode as '/'.
func encodeKeyText(s string) string // "%" -> "%25", then "/" -> "%2F"
func decodeKeyText(s string) string // "%2F" -> "/", then "%25" -> "%"
```
`maxEntryBytes = 200`, measured on the normalized text before encoding. Worst-case encoding
triples it to 600 bytes, which with a prefix under 30 bytes stays far inside 1500. Two
hundred bytes is roughly 65 Vietnamese characters — ample for a phrase, and a deliberate
refusal to let someone paste an essay into a key.
### `internal/modules/blacklist/normalize.go`
```go
// Normalize converts user text to the single form used for both storage keys
// and matching. Returns ok=false when nothing comparable is left.
func Normalize(s string) (norm string, ok bool)
```
Three steps, in this order:
1. **NFKC** via `golang.org/x/text/unicode/norm`. Canonical composition is what makes
Vietnamese portable: iOS clients may send `má` as `m` + `a` + combining acute (NFD) while
Android sends the precomposed character (NFC), and without this they are different
strings. The compatibility half additionally folds full-width and other presentation
variants onto their plain forms, which closes an evasion route for free. It does **not**
strip combining marks, so `má` stays `má` — the project owner's choice.
2. **`strings.ToLower`** after normalization, not before, because folding can otherwise
interact with composition.
3. **Whitespace collapse** — `strings.Fields` joined by a single space, which also trims.
`cat dog` and `cat dog` are the same rule; a newline is just whitespace.
Empty input, or input that is only whitespace, returns `ok=false`.
`golang.org/x/text` is already at v0.41.0 in `go.sum` as an indirect dependency, so this
promotes an existing line rather than adding a dependency.
### `internal/modules/blacklist/match.go`
```go
// Verdict is the outcome of checking one text against one thread's rules.
type Verdict struct {
Blocked bool
Entry string // the blacklist entry that decided the verdict; "" when none matched
RescuedBy string // the whitelist entry covering Entry; set only when !Blocked && Entry != ""
}
// Check judges normText. Both slices hold normalized entries and are sorted by
// the caller.
func Check(normText string, blacklist, whitelist []string) Verdict
```
The algorithm is span containment, not mere presence:
1. Collect every occurrence of every whitelist entry in `normText` as a `[start, end)` span,
by looping `strings.Index` over the remainder.
2. For each blacklist entry, in sorted order, walk its occurrences. A span is **rescued**
only if some whitelist span `[a, b)` satisfies `a <= start && end <= b`.
3. The first unrescued span wins: return `Blocked: true` naming its entry.
4. If every occurrence was rescued, return `Blocked: false` naming the first rescued entry
and the whitelist entry that covered it. If nothing matched at all, return the zero
`Verdict`.
The containment rule is the point of the phase. The tempting shortcut — *allow the text if
any whitelist entry appears anywhere in it* — passes the `assassin` case and then lets
`I met an assassin, dumbass` through, because the whitelist span never covers the second
match. A whitelist entry that merely overlaps a blacklist match without containing it does
not rescue it, which is the same rule stated from the other side.
Sorting matters for more than tidiness: `DocStore.List` gives no ordering guarantee, so
without it the same text could be reported against a different entry on each invocation.
## Files
| File | Change |
|---|---|
| `internal/modules/blacklist/scope.go` | new |
| `internal/modules/blacklist/normalize.go` | new |
| `internal/modules/blacklist/match.go` | new |
| `internal/modules/blacklist/scope_test.go` | new |
| `internal/modules/blacklist/normalize_test.go` | new |
| `internal/modules/blacklist/match_test.go` | new |
| `go.mod` | `golang.org/x/text` moves from indirect to direct |
## Steps
1. Write `scope.go` with the two key builders and the encode/decode pair.
2. Write `normalize.go`.
3. Write `match.go`.
4. Write the three test files (see Validation).
5. Run `go mod tidy`, confirming the only diff is `golang.org/x/text` changing section.
6. `gofmt` every new file.
## Validation
`go test ./internal/modules/blacklist/...` covering at minimum:
**scope_test.go**
- A negative chat ID and a zero thread ID produce a prefix ending in exactly three `:`.
- Two different threads of the same chat produce prefixes where neither is a prefix of the
other — the property that keeps `List` from leaking across threads.
- `encodeKeyText` then `decodeKeyText` round-trips text containing `/`, `%`, `%2F`, `:` and
a newline.
- A key built from a 200-byte entry passes `storage`'s key rules.
**normalize_test.go**
- The same Vietnamese word in NFC and NFD normalizes to one string.
- `Má` and `má` normalize alike; `ma` and `má` do **not**.
- A full-width variant folds onto its plain form.
- `" cat dog "` normalizes to `"cat dog"`.
- `""` and `" "` return `ok=false`.
**match_test.go** — blacklist `ass`, whitelist `assassin` unless stated:
- `assassin` → not blocked, `Entry: "ass"`, `RescuedBy: "assassin"`.
- `dumbass` → blocked.
- `I met an assassin, dumbass` → **blocked**. The regression test for the whole phase.
- `hello` → zero `Verdict`.
- Empty whitelist → `assassin` is blocked.
- An entry equal to the whole text is rescued by an identical whitelist entry.
- Shuffling the input slices does not change which entry a verdict names.
## Risk
The containment rule is easy to implement subtly wrong in a way that passes the obvious two
test cases. The mitigation is that the three-clause `assassin`/`dumbass` case is written
first and named for the behaviour it protects.
## Rollback
The package has no importers until Phase 4 wires it into `cmd/server`. Delete the directory
and revert the `go.mod` line.
@@ -1,178 +0,0 @@
---
phase: 2
title: "Phase 2: Store, factory, mutation commands"
status: done
priority: P1
effort: "3h"
dependencies: [1]
---
# Phase 2: Store, factory, mutation commands
## Overview
The module becomes a real `modules.Module`: a persisted record, a factory registering six
commands, and the four that mutate a list. The two read commands land in Phase 3, but all
six are registered here so the command surface is reviewable in one place — the unimplemented
pair point at stub handlers that Phase 3 fills in.
Blacklist and whitelist share one handler pair parameterised by list tag. They differ only
in which prefix they write to, and duplicating them would be two bugs to fix instead of one.
## Requirements
- Functional: `/blacklist_add`, `/whitelist_add` store an entry in the calling thread's list,
taking the text from the argument or, when there is none, from the replied-to message.
- Functional: `/blacklist_del`, `/whitelist_del` remove an entry, distinguishing "removed"
from "was not there".
- Functional: re-adding an existing entry is reported as such rather than silently
overwriting in silence.
- Functional: text that normalizes to nothing, or exceeds `maxEntryBytes`, is refused with a
message saying which.
- Non-functional: every handler is bounded by a timeout; none can stall the single update
worker.
- Non-functional: user text is HTML-escaped at every render site.
## Architecture
### `internal/modules/blacklist/blacklist.go`
Package doc records the two decisions a reader will otherwise re-litigate: the module is
passive by design, and lists are per-thread and world-writable.
```go
// Entry is one stored rule. The storage key holds the normalized form, so Text
// carries what the adder actually typed — the only place the original casing and
// spacing survive, and what /blacklist_rules shows.
type Entry struct {
Text string `bson:"text"`
OwnerID int64 `bson:"ownerId"`
CreatedAt int64 `bson:"createdAt"`
}
type Store = storage.DocStore[Entry]
type state struct{ store Store }
func New(deps modules.Deps) modules.Module
```
No `Registry` in `state`: nothing here needs to introspect commands the way `alias` does.
Both lists live in the one `Deps.Store` collection, separated by the key prefixes Phase 1
built. One `storage.Typed[Entry]` view serves both.
### Thread resolution
```go
// threadOf returns the scope of the message. A DM is (user's chat ID, 0) and a
// non-forum group is (chat ID, 0); neither needs a special case.
func threadOf(msg *models.Message) (chatID int64, threadID int)
```
Replies are sent with `chathelper.Reply`/`ReplyHTML`, which already forward
`MessageThreadID` so a reply in a forum topic stays in that topic rather than being routed
to General.
### `internal/modules/blacklist/handlers.go`
```go
const handlerTimeout = 10 * time.Second
const genericFailure = "Something went wrong. Try again in a moment."
```
Ten seconds matches `alias`. These handlers only touch storage, so the budget is generous.
```go
// resolveText picks the entry text out of the update: the command argument when
// there is one, otherwise the replied-to message's text. Returns the raw text
// for echoing and the normalized form for keying.
func resolveText(msg *models.Message) (raw, norm string, err error)
```
Errors are distinguishable, because "say something useful" is the whole job here:
no text found at all; normalizes to empty; longer than `maxEntryBytes`.
The reply path matters more than it looks: the natural gesture is to see a message, reply to
it, and ban it. It also makes the length cap load-bearing, since a replied-to message can
carry 4096 characters, and the refusal must say so rather than failing opaquely.
```go
func (s *state) handleAdd(list string) modules.CommandHandler
func (s *state) handleDel(list string) modules.CommandHandler
```
Closures over the list tag, so the factory registers `s.handleAdd(listBlack)` and
`s.handleAdd(listWhite)`.
`handleAdd` reads before writing to word the reply — "Added" against "Already in the
blacklist" — accepting that a concurrent add makes the noun wrong without making the stored
state wrong, the same trade `alias` documents at `handlers.go:120-123`.
`handleDel` reads before deleting for a sharper reason: `DocStore.Delete` does not
distinguish a missing key from a removed one, so without the read "Removed" would be
reported for something that never existed.
Anyone may remove anyone's entry. The list is shared by the thread, so the permission model
is too; a per-owner rule would strand entries whose adder has left the group.
### Command registration
All `VisibilityPublic`.
| Name | Parameters | Description |
|---|---|---|
| `blacklist_add` | `[text...]` | `Add text to this thread's blacklist, or reply to a message` |
| `blacklist_del` | `<text...>` | `Remove text from this thread's blacklist` |
| `whitelist_add` | `[text...]` | `Add a whitelist exception, or reply to a message` |
| `whitelist_del` | `<text...>` | `Remove a whitelist exception` |
| `blacklist_rules` | — | `List this thread's blacklist and whitelist entries` |
| `blacklist_check` | `<text...>` | `Check whether a text is blacklisted in this thread` |
`[text...]` and `<text...>` follow `docs/command-parameter-conventions.md`: square brackets
for the optional-because-a-reply-works case, the `...` suffix for remaining text.
## Files
| File | Change |
|---|---|
| `internal/modules/blacklist/blacklist.go` | new |
| `internal/modules/blacklist/handlers.go` | new |
| `internal/modules/blacklist/handlers_test.go` | new |
## Steps
1. Write `blacklist.go`: `Entry`, `Store`, `state`, `New` with all six registrations (the
two read commands pointing at stubs that reply with nothing yet).
2. Write `threadOf` and `resolveText`.
3. Write `handleAdd` and `handleDel` as list-parameterised closures.
4. Write `handlers_test.go` against `storage.NewMemoryProvider()` and the test bot harness
used by `internal/modules/alias/handlers_test.go`.
5. `gofmt`, then run the package tests.
## Validation
`go test ./internal/modules/blacklist/...`:
- Adding then reading back the key confirms the entry landed under the right prefix with
`Text` as typed, not as normalized.
- The same text added in two different threads of one chat produces two entries, and
removing one leaves the other.
- `/blacklist_add` with no argument and no reply returns usage text, not a stored entry.
- `/blacklist_add` replying to a message stores that message's text.
- A 4096-character replied-to message is refused with the length message.
- Text that is only whitespace or only punctuation stripped by normalization is refused.
- Adding an existing entry replies "already", and the stored record is unchanged.
- `/blacklist_del` for an absent entry replies "not there" and is not reported as removed.
- An entry containing `<b>` renders escaped in every reply that echoes it.
- `/whitelist_add` writes under the `w` prefix and is invisible to a `b`-prefix list.
## Risk
`resolveText` is where a caller's 4096-character message meets a 200-byte key. Getting the
order wrong — keying before checking the length — turns a chatty refusal into an opaque
storage error. Normalize, then measure, then key.
## Rollback
Still no importers outside the package. Revert the two files; Phase 1 stands alone.
@@ -1,118 +0,0 @@
---
phase: 3
title: "Phase 3: /blacklist_rules and /blacklist_check"
status: done
priority: P1
effort: "2h"
dependencies: [2]
---
# Phase 3: `/blacklist_rules` and `/blacklist_check`
## Overview
The two read commands, replacing the Phase 2 stubs. `/blacklist_check` is where the Phase 1
matcher finally meets stored data; `/blacklist_rules` is where arbitrary user text meets
Telegram's message limit.
## Requirements
- Functional: `/blacklist_check` reports a verdict naming the blacklist entry that decided it
and, when the text was rescued, the whitelist entry responsible.
- Functional: `/blacklist_rules` shows both lists in one message, each under its own heading,
with empty lists visibly empty rather than absent.
- Functional: a listing too long for one Telegram message is trimmed with a count of what was
omitted.
- Non-functional: `/blacklist_check` costs one `List` per list and no per-entry reads.
- Non-functional: both commands are bounded by `handlerTimeout` and escape all user text.
## Architecture
### Loading a thread's entries
```go
// entriesFor returns one list's normalized entries, sorted. The key holds the
// normalized text, so this needs no per-entry document read — the difference
// between one round trip and one per rule.
func (s *state) entriesFor(ctx context.Context, chatID int64, threadID int, list string) ([]string, error)
```
`DocStore.List` returns full keys, as `alias` relies on at `handlers.go:260`. Strip
`scopePrefix(...)` with `strings.TrimPrefix`, run `decodeKeyText`, sort.
### `/blacklist_check`
Normalize the argument through `Normalize`, load both lists, call `Check`, and render the
`Verdict`'s three shapes:
- `Blocked` — say so and name the entry.
- Not blocked but `Entry != ""` — say it is allowed, name the entry that matched, and name
the whitelist entry that rescued it. This case is the reason the whitelist is worth having
a display for at all: without it, a user who added an exception has no way to confirm it
is doing anything.
- Zero `Verdict` — nothing matched.
Each reply also states the scope, so a user who runs the command in the wrong topic can see
why the answer surprised them.
### `/blacklist_rules`
```go
const maxListBytes = 3800
```
The same budget `alias` uses (`internal/modules/alias/handlers.go:29-36`) and for the same
reason: Telegram's 4096-character `sendMessage` cap measures the message actually sent, so
the `<code>` tags count too, and at 13 bytes a pair they outweigh a short entry.
One message, two headed sections, blacklist first. Entries come from the stored `Entry.Text`
rather than the key, so the list shows what people typed — which costs one read per *listed*
entry, bounded by what fits in the message rather than by how many entries exist, exactly as
`alias.renderNames` is. Each entry is wrapped in `<code>` so tapping it copies the text ready
to paste into a `_del` command.
Both sections share the single byte budget; the trim notice names how many entries were
omitted. An empty list prints its heading followed by a short "none yet" line, because a
missing heading reads as a bug rather than as an empty list.
## Files
| File | Change |
|---|---|
| `internal/modules/blacklist/handlers.go` | stubs replaced; `entriesFor`, `renderRules` added |
| `internal/modules/blacklist/handlers_test.go` | extended |
## Steps
1. Write `entriesFor`.
2. Replace the `/blacklist_check` stub; render the three `Verdict` shapes.
3. Replace the `/blacklist_rules` stub; write `renderRules` with the shared byte budget.
4. Extend `handlers_test.go`.
5. `gofmt`, run the package tests.
## Validation
`go test ./internal/modules/blacklist/...`:
- The plan's headline case end to end: blacklist `ass`, whitelist `assassin`, then
`/blacklist_check assassin` is allowed and names both entries, `/blacklist_check dumbass`
is blocked, and `/blacklist_check I met an assassin, dumbass` is **blocked**.
- `/blacklist_check` against empty lists reports nothing matched.
- `/blacklist_check` in thread B does not see entries added in thread A.
- `/blacklist_rules` on empty lists prints both headings and no entries.
- `/blacklist_rules` shows `Entry.Text` as typed, not the normalized form.
- Enough entries to exceed `maxListBytes` produce a message under 4096 characters carrying an
accurate omitted count.
- An entry containing `<b>` appears escaped.
- `entriesFor` returns sorted output given keys listed in any order.
## Risk
`renderRules` splits one byte budget across two sections; a naive implementation gives the
first section the whole budget and leaves the whitelist permanently invisible on a busy
thread. Reserve the trim notice before committing a line, as `alias.renderNames` does, and
test with a blacklist alone large enough to exhaust the budget.
## Rollback
Restore the Phase 2 stubs. The mutation commands stay usable.
@@ -1,106 +0,0 @@
---
phase: 4
title: "Phase 4: Wiring and documentation"
status: done
priority: P2
effort: "1h"
dependencies: [3]
---
# Phase 4: Wiring and documentation
## Overview
Register the module in the composition root and document it. This is the phase that makes
the six commands reachable by a real user.
## Requirements
- Functional: the module is in `factories()`, so it loads by default and can be selected or
omitted through `MODULES`.
- Functional: `/help` and Telegram's native command menu list all six commands, which follows
automatically from registration and needs only to be asserted.
- Non-functional: README and a feature doc describe the behaviour a user can observe,
including the two things most likely to surprise them.
## Architecture
### `cmd/server/main.go`
One line in `factories()` (`cmd/server/main.go:80-99`):
```go
"blacklist": blacklist.New,
```
Plain string rather than a `CollectionName` constant, matching `alias`, `gold` and `misc`;
only modules whose name is referenced elsewhere export one.
An unset or empty `MODULES` loads every registered module (`internal/modules/registry.go:178`),
so this line alone enables it on the existing deployment. No `init*Store` call is needed —
the module has no startup migration and no cross-module state.
### `docs/blacklist.md`
Following `docs/aliases.md` in shape. It has to answer the three questions a user will
actually arrive with:
- **The bot does not police chat.** The lists are inert until `/blacklist_check` asks. Worth
stating first and plainly, because the module's name promises enforcement it deliberately
does not perform.
- **Lists are per topic.** Entries added in one forum topic are invisible in the next, and a
DM has its own private list. This is the most likely support question.
- **Diacritics are significant.** `ma`, `má` and `mà` are three entries; catching all three
means adding all three. Case and spacing are not significant, and the same word typed on
an iPhone and on Android match.
Plus a worked whitelist example, since span containment is not guessable: blacklist `ass`,
whitelist `assassin`, and the three outcomes including `I met an assassin, dumbass`.
### `README.md`
One row in the module table:
```text
| `blacklist` | Per-topic text deny-list with whitelist exceptions: `/blacklist_add`, `/blacklist_del`, `/whitelist_add`, `/whitelist_del`, `/blacklist_rules` lists both, `/blacklist_check` judges a text. Passive — the bot never scans chat. See [docs/blacklist.md](docs/blacklist.md) |
```
## Files
| File | Change |
|---|---|
| `cmd/server/main.go` | import + one `factories()` entry |
| `cmd/server/main_test.go` | extended |
| `docs/blacklist.md` | new |
| `README.md` | one table row |
## Steps
1. Add the import and the `factories()` entry.
2. Extend `TestFactoriesIncludesExpectedModules` (`cmd/server/main_test.go:111`) to build a
registry containing `blacklist` and assert all six command names resolve.
3. Write `docs/blacklist.md`.
4. Add the README row.
5. `gofmt`, then the full gate below.
## Validation
- `go test ./...`
- `go vet ./...`
- `golangci-lint run`
- `MODULES=blacklist` builds a registry with exactly the six commands and no conflict.
- Unset `MODULES` builds a registry including `blacklist` alongside every existing module —
the check that no command name collides with an existing one.
- Every link in `README.md` and `docs/blacklist.md` resolves.
## Risk
A command-name collision surfaces only when the full catalog is built, because
`Registry.addCommands` rejects duplicates across modules at startup. The unset-`MODULES`
test is what turns that from a deploy-time crash into a test failure. `blacklist_*` and
`whitelist_*` are unused by any existing module today, so the expected result is green.
## Rollback
Revert the `factories()` entry; the package becomes dead code without affecting a running
deployment. Revert the docs separately.
-162
View File
@@ -1,162 +0,0 @@
---
title: "Blacklist module"
description: "internal/modules/blacklist — per-thread deny-list and exception-list of text, curated by anyone, queried on demand. Passive: the bot never acts on chat traffic."
status: done
priority: P2
effort: ""
tags: ["blacklist", "telegram-bot", "module"]
created: 2026-09-15
branch: main
blockedBy: []
blocks: []
---
# Blacklist module
## Overview
New module `internal/modules/blacklist`. Each chat thread curates two lists of text: a
**blacklist** of forbidden entries and a **whitelist** of exceptions that rescue false
positives. `/blacklist_check <text>` scans a text against that thread's rules and reports a
verdict.
The module is **passive**. It never reads ordinary chat messages and never deletes, warns,
or restricts anyone. It only answers when a command is invoked.
## Accepted scope
| Decision | Value |
|---|---|
| Behaviour | Passive registry. No message scanning, no enforcement |
| Scope | Per thread — `(Chat.ID, MessageThreadID)`; a DM is `(userID, 0)` |
| Visibility | All `VisibilityPublic`; anyone in a thread may modify that thread's lists |
| Matching | Substring, in normalized space |
| Normalization | NFKC + lowercase + whitespace collapse. **Diacritics preserved** |
| Whitelist | Exception layer — rescues a blacklist match only by span containment |
### Why passive
Enforcement would need a `Module.MessageHook` primitive the dispatcher does not have
(`internal/modules/dispatcher.go` registers only commands, callback prefixes, one fallback
and one inline query), plus privacy mode disabled in BotFather and group-admin delete
rights. All of that is out of scope. Nothing in this plan forecloses adding it later: the
matcher is a pure function a future hook can call unchanged.
### Why diacritics are preserved
Chosen by the project owner. `ma`, `má` and `mà` are three distinct entries. The cost is
that dropping a diacritic evades an entry, so each form worth catching must be added
separately — a curation choice, not a defect. NFKC still runs, so full-width and
compatibility variants of the same characters do collapse, and Vietnamese text composed as
NFD by iOS clients matches the same text composed as NFC by Android clients.
## Command surface
All `VisibilityPublic`. `Parameters` follows `docs/command-parameter-conventions.md`.
| Command | Parameters | Behaviour |
|---|---|---|
| `/blacklist_add` | `[text...]` | Add to this thread's blacklist. No argument → the replied-to message's text |
| `/blacklist_del` | `<text...>` | Remove from this thread's blacklist |
| `/whitelist_add` | `[text...]` | Add an exception. No argument → the replied-to message's text |
| `/whitelist_del` | `<text...>` | Remove an exception |
| `/blacklist_rules` | — | This thread's blacklist and whitelist entries, in one message |
| `/blacklist_check` | `<text...>` | Verdict for the text, naming the entry that matched and any exception that rescued it |
`/blacklist_rules` carries the `blacklist_` prefix so Telegram's native menu sorts it beside
the other `blacklist_*` commands, and its description states that it shows both lists.
## Goals
| # | Goal | Priority |
|---|------|----------|
| 1 | Two threads of one forum keep entirely separate lists | P1 |
| 2 | A whitelist entry rescues only the blacklist matches it actually spans | P1 |
| 3 | Vietnamese text matches regardless of the client's Unicode composition | P1 |
| 4 | No command can stall the bot beyond a bounded deadline | P1 |
| 5 | Arbitrary user text is safe as a storage key and as Telegram HTML | P1 |
| 6 | A list too long for one Telegram message is trimmed, not dropped | P2 |
## Phases
| # | Phase | Depends on | Effort |
|---|---|---|---|
| 1 | [Scope keys, normalization, matcher](phase-01-scope-normalize-match.md) | — | done |
| 2 | [Store, factory, mutation commands](phase-02-store-and-mutations.md) | 1 | done |
| 3 | [`/blacklist_rules` and `/blacklist_check`](phase-03-rules-and-check.md) | 2 | done |
| 4 | [Wiring and documentation](phase-04-wiring-and-docs.md) | 3 | done |
Phases 2 and 3 landed in one edit: both write `internal/modules/blacklist/handlers.go`,
so the planned stub-then-replace step was skipped.
Phase 1 is pure Go with no Telegram and no storage, so it carries the whole matcher
test suite and can be reviewed on its own.
## Acceptance criteria
1. Adding, listing, checking and deleting in two threads of the same forum supergroup do not
leak across threads; the same commands work in a DM.
2. Given blacklist `ass` and whitelist `assassin`: `assassin` is ALLOWED, `dumbass` is
BLACKLISTED, and `I met an assassin, dumbass` is BLACKLISTED.
3. `má` does not match an entry `ma`; `Má` does match an entry `má`; the same Vietnamese
word in NFC and NFD form match each other.
4. An entry containing `/`, `%`, `:` or a newline round-trips through storage and renders
escaped in `/blacklist_rules`.
5. `/blacklist_rules` with more entries than fit in 4096 characters returns a trimmed
message with a count of what was omitted.
6. `go test ./...`, `go vet ./...` and `golangci-lint run` all pass.
## Stats compatibility
Six new command names, no rename and no deletion, so `AGENTS.md`'s stats migration rules
impose no work. Command names must not change after release without the migration those
rules require.
## Risks
| Risk | Mitigation |
|---|---|
| User text used as a storage key hits the `/`-forbidden and 1500-byte rules in `internal/storage/keys.go` | Percent-encode `%` then `/`; cap normalized entries at 200 bytes (Phase 1) |
| `DocStore.List` has no ordering guarantee, so verdicts could name different entries across runs | Sort entries before scanning (Phase 1) |
| Unescaped user text in an HTML reply | Every entry passes through `html.EscapeString` at every render site (Phases 2-3) |
| `golang.org/x/text` promoted from indirect to direct dependency | Already present at v0.41.0 in `go.sum`; no new download, `go mod tidy` only moves the line |
## Outcome
All four phases delivered; every acceptance criterion above verified. Full gate green:
`go test ./...`, `go vet ./...`, `golangci-lint run` (0 issues).
Two files the plan did not anticipate had to change, both required by existing repo
contracts rather than by the feature:
- `cmd/server/command_menu_test.go` pins every public command's `Parameters` string, and
its reverse loop makes even an empty entry load-bearing. The six new commands are
registered there.
- The six command `Description` strings were shortened after review. `/help` renders as one
un-chunked message pinned at 4096 runes, and the first draft left 9 runes of headroom;
the shorter wording restores it to 76. See the open questions below — that ceiling is a
shared limit this module did not create and cannot fix alone.
`/blacklist_rules` reads each list with `DocStore.Scan`, which landed on `main` while this
work was in progress. The phase-3 design called for `List` followed by a `Get` per displayed
entry, copying `alias.renderNames`; `Scan` exists precisely to remove that N+1, and this
command paid it twice per invocation. `/blacklist_check` still uses `List`, because it needs
only the entry names and never their stored text.
Test integrity was checked by mutation: fourteen mutants were injected against the matcher,
key encoding, normalization, trimming and escaping. The four that initially survived —
three HTML-escaping sites and the post-normalization byte cap — are now covered by tests
verified to fail without the guard they pin.
## Post-review decisions
1. **Reply-thread scoping.** `threadOf` now gates on `msg.IsTopicMessage` rather than
trusting `msg.MessageThreadID` alone. Telegram associates a thread id with any reply chain
in a supergroup, not only with a forum topic, so the original code would have given a
reply-form `/blacklist_add` in a plain supergroup a scope that a later standalone
`/blacklist_rules` could never read back. A forum topic keeps its own lists; a plain
group, a DM, and a forum's General topic all resolve to thread 0. Pinned by
`TestReplyChainThreadIsNotATopic`, verified to fail without the gate.
2. **Per-thread entry count stays unbounded**, matching `alias`, which is equally
world-writable and equally uncapped. Entries remain capped at 200 bytes each. Revisit only
if a thread's list grows large enough to make `/blacklist_check` slow.
@@ -1,28 +0,0 @@
# Plan: `thoitiet` weather module
Status: done (2026-10-01). Design and decisions:
[research report](../reports/research-261001-0900-thoitiet-weather-module.md).
## Outcome
Public commands `/thoitiethomnay` (alias `/thoitiet`), `/thoitietngaymai`, and
`/thoitiettuannay`, each taking `[location...]` and defaulting to Ho Chi Minh
City, backed by Open-Meteo geocoding and forecast APIs.
## Steps
1. `internal/modules/thoitiet`: `thoitiet.go` (registration + handlers),
`api_client.go` (geocode + forecast), `location.go` (diacritic stripping,
aliases, result choice), `format.go` (WMO labels, three renderers).
2. Tests for location parsing, formatting, and handlers against `httptest`.
3. Wire `"thoitiet": thoitiet.New` in `cmd/server/main.go`; pin the new
parameter strings in `cmd/server/command_menu_test.go`.
4. README module table row.
## Acceptance criteria
- No argument shows Ho Chi Minh City without a geocoding request.
- `Đà Lạt`, `Da Lat`, `hcm`, `saigon`, and foreign names like `Tokyo` resolve.
- `/thoitiettuannay` lists 7 days starting today in the location's timezone.
- Unknown location and upstream failure reply with Vietnamese error text.
- `go test ./...`, `go vet ./...`, and `golangci-lint run` pass.
@@ -1,41 +0,0 @@
# /gacha wish command
Status: done
## Outcome
`/gacha <option[*rarity],...>` picks one option and replies with a Genshin
Impact style wish animation (meteor coloured by rarity, white flash, reveal
with stars), rendered as a silent MP4 by the existing wheelofnames service.
## Decisions (user-approved 2026-10-01)
- Renderer lives in `tiennm99/wheelofnames` as `POST /api/gacha`; no new repo
or deployment.
- Rarity is a user prefix: `Pizza, 4* Pho, 3* Rice` (unprefixed = 5★;
switched from the original `Pizza*5` suffix with a 3★ default on 2026-10-01).
- Single pull only.
- Output MP4 (H.264, no audio) sent via `sendAnimation`.
## Design
- Every option is equally likely, as with /random; the rarity tag is
cosmetic. (First shipped with Genshin tier rates; the user switched to
uniform odds on 2026-10-01.)
- All visuals are procedural (CSS/gradients); no HoYoverse assets.
- Bot derives the gacha endpoint from `WHEELOFNAMES_API_URL` (sibling path
`gacha` next to `gif`), so no new env var. Same bearer token.
- Bot is the source of truth for winner and rarity; renderer only draws.
- Fallback without renderer or on failure: text reply `★★★★★ Pizza`.
## Phases
1. Renderer: schema, `GachaComposition`, MP4 render, route, tests, docs.
2. Bot: parser, weighted pick, API client, command, tests, README/docs.
3. Verify: renderer stills via Docker, lint/typecheck/test, go test/vet/lint.
## Acceptance
- Each rarity renders with the correct meteor/glow colour and star count.
- `/gacha` with no options shows usage; every option is equally likely, like /random.
- Renderer failure falls back to text; thread IDs forwarded.
@@ -1,50 +0,0 @@
# Plan: `/thuyvan` flood alerts for Tân Thuận
Status: implemented, not yet committed (2026-10-02)
Design: [research report](../reports/research-261002-1205-thuyvan-flood-alert.md)
## Outcome
`/thuyvan` (no parameter) shows Tân Thuận's flood risk: a 5-day tide-peak
forecast at Phú An and Nhà Bè from the KTTV Nam Bộ bulletin, the rain forecast
from Open-Meteo, and live VNDMS levels at stations within 30 km. Chats that opt
in with `/thuyvan_subscribe` get a 10:30 ICT push, but only when some day has a
tide peak of at least 1.40 m (BĐ I) or at least 50 mm of rain.
## Constraints and non-goals
- The `thoitiet` module is renamed to `weather`. The command names stay the
same.
- Subscriber storage and push fan-out move out of `lol` into a shared helper,
and `lol` behaviour stays the same.
- Non-goals: other locations, tide modelling, or an evening push.
## Phases
1. [x] Rename `internal/modules/thoitiet` to `internal/modules/weather`
(package, catalog key, README).
2. [x] Extract `internal/modules/util/subscription`: the subscriber list, the
terminal-error classifier, the per-day claim and fan-out with pruning.
Move `lol` onto it.
3. [x] Tide bulletin: homepage → article → PDF → peak rows, with a strict
validator and a real PDF fixture.
4. [x] VNDMS: parse the popups, tag alarm tiers, keep stations within 30 km of
Tân Thuận, using a trimmed fixture.
5. [x] `/thuyvan`, the subscribe and unsubscribe commands, and the 10:30 ICT
cron. Wire up the menu test and the README.
6. [x] Run `go test ./...`, `go vet ./...` and `golangci-lint run`, then
review.
## Acceptance
- `/thuyvan` renders today's bulletin (fixture) with the BĐ tiers. When a
source fails, its section is replaced by a note.
- The push is sent only on risk days, once per ICT day, and prunes dead chats.
- The `lol` tests pass unchanged in behaviour.
## Risk
- The bulletin PDF layout could drift. The strict row validator turns that
into a missing-bulletin note.
- `MODULES=thoitiet` fails startup after the rename. Check Coolify before
pushing.
@@ -1,83 +0,0 @@
---
status: completed
mode: port
---
# Port monkeyd-crawler into the monkeyd module
## Outcome
The bot builds without the `third_party/monkeyd-crawler` submodule. The crawler,
PDF renderer, and export flow live in-tree under `internal/modules/monkeyd/`,
trimmed to what the bot uses. Coolify deploys succeed again.
Why now: `tiennm99/monkeyd-crawler` was merged into `tiennm99/mttools` and the
old repo is gone, so every recursive clone fails (Coolify deployment
`6klach0etmr5ymnfrvbkq77a`, "Repository not found").
## Source manifest
- Source: `tiennm99/mttools`, path `monkeyd-crawler/`, commit `03b1400`
- It matches the pinned submodule commit `d88f2a4` except for import paths
- License: Apache-2.0, same author as this repo (also Apache-2.0); bundled
DejaVu Sans keeps its `NOTICE.md`
## Source anatomy
| Package | Role | Bot uses |
|---|---|---|
| `monkeyd` | HTTP client (rate limit, retries, size cap), disk cache, novel and chapter parsing, CSS word-class decoding, worker pool | `Crawler.NovelInfo` and `NewClient` for `/monkeyd_tags`, plus the export path |
| `pdfout` | fpdf layout, page presets, font discovery plus bundled DejaVu | the export path, plus `Presets` in a test |
| `export` | orchestration: defaults, validation, crawl, render | `Export`, `Request`, `Result`, `DefaultFontSize`, `DefaultDelay` |
| `cmd/monkeyd-crawler` | CLI | nothing |
## Dependency matrix
| Source | Local | Status |
|---|---|---|
| `monkeyd` package | `internal/modules/monkeyd/crawler` | NEW (port as-is) |
| `pdfout` package | `internal/modules/monkeyd/pdf` | NEW (drop system font discovery) |
| `export` package | `internal/modules/monkeyd/export` | NEW (trim the request surface) |
| CLI | none | DROPPED |
| `go-pdf/fpdf`, `x/net`, `x/sync`, `x/image` | `go.mod` | EXISTS (indirect deps become direct) |
| submodule, `replace`, Dockerfile COPY, CI `submodules: true` | none | REMOVED |
## Decision matrix
| Decision | Source's way | Our way | Recommendation |
|---|---|---|---|
| Distribution | Go module in mttools | in-tree packages | In-tree, as requested. No cross-repo pin to drift, and Coolify needs no recursive clone |
| Font | system font discovery, then bundled | bundled only | Bundled only. Production has no system fonts, so its output is unchanged, and dev machines render the same PDF |
| Page presets | phone, a5, a4 | phone only | Fixed phone page; the bot exposes no page option |
| Export knobs | page, font file, spacing, margin, workers, retries, delay, limit, out path, no-cache, no-delay | URL, out dir, font size, cache dir, log | Keep only what the bot sets. The rest become constants with the source's defaults |
| CLI | `cmd/monkeyd-crawler` | none | Drop; mttools keeps the CLI |
Risk score: 3 of 10. The logic is Go to Go and identical to what runs today.
The main risk is a trim that drops behavior, and the ported tests cover that.
## Phases
1. Port the packages. Copy `monkeyd`, `pdfout`, and `export` with their tests
into `internal/modules/monkeyd/{crawler,pdf,export}`. Rewrite imports and
apply the trims above. Remove tests that only cover dropped features.
2. Rewire the module. Point `export_job.go`, `monkeyd.go`, `tags_command.go`,
and `handlers_test.go` at the new packages.
3. Remove the submodule. Delete `third_party/monkeyd-crawler`, `.gitmodules`,
and the `replace`/`require` lines; run `go mod tidy`. Drop the Dockerfile
COPY and CI `submodules: true`.
4. Update docs: README (layout, run locally, clone instructions), AGENTS.md,
and `docs/deploy-coolify-selfhosted.md` (recursive clone note).
## Acceptance criteria
- `go build ./...`, `go vet ./...`, `go test ./...`, and `golangci-lint run`
pass with no submodule present
- `docker compose build` passes
- A live export of a real novel URL produces a PDF with Vietnamese diacritics
- The Coolify deployment of the pushed commit finishes
## Rollback
Revert the commits. The old submodule URL no longer resolves, so a rollback
needs `.gitmodules` pointed at `tiennm99/mttools` with the path adjusted, or a
module dependency on `github.com/tiennm99/mttools/monkeyd-crawler`.
@@ -1,85 +0,0 @@
---
phase: 1
title: "Code rebrand"
status: done
owner: agent
---
# Phase 1 — Code rebrand
## Context
Every in-repo `miti99bot` occurrence, listed in the
[scout report](../reports/scout-261007-1423-rebrand-tiennm99bot.md). The bot's
own username is already runtime-resolved (`BOT_USERNAME` or getMe), so nothing
here depends on the new Telegram account.
## Steps
1. **Go module path.** In `go.mod`, change it to `github.com/tiennm99/tiennm99bot`,
then rewrite every import:
`git ls-files '*.go' | xargs sed -i 's#github.com/tiennm99/miti99bot#github.com/tiennm99/tiennm99bot#g'`.
`util/help.go` `repoURL` and `util/help_test.go` follow from the same sed.
2. **Runtime strings** → `tiennm99bot`:
- `internal/server/health.go` health body, plus the `compose.yml` and
`docs/deploy-coolify-selfhosted.md` text that quotes it.
- `internal/deploynotify/deploy_notify.go` DM text, plus its test.
- User-Agents in `coin/price_providers.go`, `gold/vnappmob_client.go` (×2),
`stock/prices_ssi.go`, `lol/api_client.go` (`userAgentProduct`), plus
`stock/prices_test.go` and `lol/api_client_test.go`.
- `monkeyd/export_job.go` `cacheDirName`.
- `renderer/src/gacha/page/page.js` edition label.
3. **Sticker slug.** Set `sticker/sticker_pack.go` `defaultStickerPackSlug` to the
confirmed slug (proposed `stickers`). Update the comment example and the tests
(`addsticker_command_test.go`, `sticker_pack_test.go`), plus the examples in
`docs/sticker-packs.md`, `.env.example` and `compose.yml`.
4. **Test fixtures.** Change `/cmd@miti99bot` to `@tiennm99bot` in
`modules/dispatcher_test.go` and `alias/fallback_test.go`, and the comment in
`stats/views.go`. Change the test DB prefixes `miti99bot_*` to
`tiennm99bot_*` in four `*_mongo*_test.go` files. Update the
`sticker_pack_test.go` username fixture. Leave `misc/handlers_test.go`
`@miti99` alone, because it is a user handle.
5. **loldle stickers.** Update the `loldle/stickers.go` comment to name
@tiennm99bot. The file_ids are replaced in phase 2, because they need the
new bot.
6. **Build/deploy config.**
- `.github/workflows/ci.yml` image tags `tiennm99bot`, `tiennm99bot-renderer`.
- `compose.yml`: the commented GHCR image and the DB example.
- `.env.example`: the header and `MONGO_DATABASE=tiennm99bot`.
- `renderer/package.json` name `tiennm99bot-renderer`. Regenerate the lock
file with `npm install --package-lock-only` in `renderer/`; do not edit it
by hand.
7. **Docs.**
- `README.md` (title, local mongo container name, `_dev` DB), `AGENTS.md`,
`CLAUDE.md`, `docs/deploy-coolify-selfhosted.md`.
- `renderer/README.md`, `renderer/docs/deployment.md`.
- `git mv renderer/docs/miti99bot-integration.md renderer/docs/tiennm99bot-integration.md`,
then fix its links (`git grep -n miti99bot-integration`).
8. **Sweep.** `git grep -n -i miti99bot -- ':!plans'` must be empty. Review any
remaining `miti99` hits by hand.
## Validation
```sh
gofmt -l . && go vet ./... && go test -race -count=1 ./... && go build ./...
(cd renderer && npm ci && npm run lint && npm run typecheck && npm test)
docker build -t tiennm99bot . && docker build -t tiennm99bot-renderer renderer
```
## Commit
Use two focused conventional commits on `main`:
- `feat(config): resolve the bot username from BOT_USERNAME or getMe` (the existing uncommitted change)
- `refactor!: rebrand miti99bot to tiennm99bot`
The body notes that the default sticker pack and the module path changed.
Delay the push to the cutover in phase 2 if the old bot should keep its old strings.
## Risk and rollback
- Go module path change: purely mechanical, and the build and tests catch any miss.
- Pushing deploys to Coolify. If it is pushed before cutover, the only visible
effects are new strings and a new default pack name on the *old* bot, where
`/addsticker` would create `stickers_by_miti99bot`. To avoid that, set
`STICKER_PACK_NAME=miti99_by_miti99bot` in Coolify until cutover, or hold the push.
- Rollback: `git revert` the rebrand commit.
@@ -1,140 +0,0 @@
---
phase: 2
title: "Cutover and manual steps"
status: in-progress
owner: user + agent
blockedBy: [phase-01]
---
# Phase 2 — Cutover and manual steps
Legend: **[you]** only you can do it (BotFather, Atlas UI, Coolify secrets).
**[agent]** I can run it once you approve that step.
Verified facts (2026-10-07):
- The Coolify app `miti99bot` is on **miti-sg** (uuid `ofo63lqv73huw1hce24ntg9i`).
- It has no `STICKER_PACK_NAME` and no `BOT_USERNAME` set.
- The name `tiennm99/tiennm99bot` is free on GitHub.
- `mongodump`/`mongorestore` are not installed here, but the local `mongo:8`
image ships them.
## A. Prepare (no downtime)
1. **[you] Create the bot.** In BotFather, run `/newbot` → username `tiennm99bot`
and keep the token. Then:
- `/setinline` on @tiennm99bot. The alias inline picker needs it
([docs/aliases.md](../../docs/aliases.md)).
- Optionally set `/setdescription`, `/setabouttext`, `/setuserpic`, and
`/setjoingroups` (keep enabled).
- The command menu needs nothing here: the bot registers it at startup.
2. **[you] Start the new bot.** Open @tiennm99bot from the `OWNER_ID` account
and press Start. Without that, the deploy DM fails with 403. Admins should
do the same.
3. **[you] Create the Atlas DB user.** Make a user with `readWrite` on database
`tiennm99bot` only, and build the new `MONGO_URL` with it. Keep the old user
until step D.
4. ✅ **Done 2026-10-07.** **[agent] Rename the repo.** Run `gh repo rename tiennm99bot -R tiennm99/miti99bot`.
GitHub keeps a redirect from the old URL. Then set the local `origin` to
`https://github.com/tiennm99/tiennm99bot.git`.
5. ✅ **Done 2026-10-07.** **[agent] Move the checkout.** Move `/workspace/tiennm99/miti99bot` to
`/workspace/tiennm99/tiennm99bot`. `/tiennm99/` is already ignored at the
workspace root. Restart the Claude session from the new path, because
project memory is keyed by path.
## B. Cutover window (bot offline about 10–15 min)
1. **[agent] Stop the app.** Use `control stop` on the Coolify app (confirm
required) so nothing writes during the copy.
2. **[agent] Back up and copy the database**, using the `mongo:8` image, with
the URL passed via env and never echoed:
- `mongodump --uri "$OLD_URL" --db miti99bot --archive=miti99bot-<date>.archive --gzip`
Keep this archive outside the repo as the backup.
- `mongorestore --uri "$NEW_URL" --archive=... --gzip --nsFrom 'miti99bot.*' --nsTo 'tiennm99bot.*'`
This restores indexes too.
- Compare `countDocuments` for every collection in both DBs with `mongosh`.
Any mismatch means stop and fix before going further.
- A first copy already ran on 2026-10-07 15:10 from `.env` `OLD_MONGO_URL`
/ `OLD_MONGO_DATABASE` (249 docs, counts and indexes match; backup
`~/backups/mongo/miti99bot-20261007-1510.archive.gz`). The old bot kept
writing after it, so at cutover use `--drop` on the restore to re-sync.
- Docker bind mounts land on the daemon host, not this workspace: stream
with `--archive` to stdout/stdin instead of `--out`.
3. **[you] Update the Coolify app env** in the dashboard (the MCP never writes
secret values):
- `TELEGRAM_BOT_TOKEN` = the new token
- `MONGO_URL` = the new user's URL
- `MONGO_DATABASE=tiennm99bot`
- Optionally `BOT_USERNAME=tiennm99bot`, which skips getMe.
- Do **not** set `STICKER_PACK_NAME`, so the default `stickers_by_tiennm99bot`
applies.
- Apply the same values to the preview copies, or delete those.
4. **[you] Rename and repoint the app.** Rename the Coolify app to `tiennm99bot`
and set its git repository to `tiennm99/tiennm99bot`, branch `main`. Check
that the GitHub App source still sees the repo after the rename.
5. **[agent] Deploy.** Push the phase 1 commits to `main` if they are held, or
trigger `deploy`. Then watch `get_deployment` until it is running:healthy.
## C. Verify (agent checks plus you in Telegram)
1. **[agent]** The logs show `bot username ... source getMe|BOT_USERNAME` = `tiennm99bot`,
`webhook cleared`, `telegram long polling started`, and no Mongo errors.
2. **[you]** The owner receives "🚀 tiennm99bot deployed: <sha>" from @tiennm99bot.
3. **[you]** Smoke-test in DM and in one group:
- `/help`
- `/stats` (old counts present, which proves the data moved)
- `/lol`, `/stock`, `/gold`, `/coin`
- `@tiennm99bot` inline
- `/wheelofnames`
4. **[you]** Run `/addsticker` replying to a sticker. It creates
`stickers_by_tiennm99bot`. The old `miti99_by_miti99bot` pack stays with the
old bot. Re-adding its stickers to the new pack is manual, one `/addsticker`
each.
5. **[you] Recapture the loldle stickers.** Send each wanted sticker to
@tiennm99bot and capture its file_id with `/stickerid`. **[agent]** puts
them in `internal/modules/loldle/stickers.go`, then commits and deploys.
Until then, loldle simply sends no sticker, because the errors are already
ignored.
## D. Migration window and cleanup
1. **[you] Move users and groups:**
- Add @tiennm99bot to every group that used @miti99bot.
- Users must Start the new bot in DM.
- Stored subscriptions (lol daily push and others) are keyed by chat ID, so
they stay valid. They deliver once the new bot is in that chat; until
then, the fan-out logs 403s for those chats.
- Announce the move from @miti99bot. Since its token is unused, send the
announcement manually from your account, or post a message in each group.
2. **[you] Retire the old bot** after the window (suggested 2–4 weeks).
Options: `/revoke` the old token in BotFather, `/deletebot`, or keep the
name parked to stop impersonation (recommended: keep it parked and revoke
the token).
3. **[agent] Drop the old database** after verification and the window. Run
`db.getSiblingDB('miti99bot').dropDatabase()` only after a fresh count
check, and keep the dump archive. **[you]** then delete the old Atlas user.
4. **[agent] Clean up.** Delete the local `miti99bot` and `miti99bot-renderer`
docker images if any remain. Update workspace memory or notes that mention
the old name.
## Rollback
Before D.3, rollback is quick:
1. Restore the old `TELEGRAM_BOT_TOKEN`, `MONGO_URL` and `MONGO_DATABASE`.
2. `git revert` the rebrand commit.
3. Redeploy.
The old DB is untouched until D.3. Any writes made to the new DB after cutover
would be lost on rollback.
## File_id migration (done 2026-10-07 16:10)
- Sticker file_ids turned out to work across bots: the 6 sticker aliases and
all 6 loldle stickers send from @tiennm99bot unchanged, so C.5 needs no code
change. Photo and animation file_ids do not transfer.
- The 9 photo/animation aliases were downloaded with the old token, re-uploaded
through the new bot to the owner DM (messages deleted), send-tested, and
their `fileId` updated only where unchanged. Three GIFs were stored without
a file extension and had to be uploaded as `.mp4` to stay animations.
- The 8 stickers of `miti99_by_miti99bot` were added to
`stickers_by_tiennm99bot` by file_id (pack now 9 with the /addsticker test).
- Backup before the update: `~/backups/mongo/tiennm99bot-20261007-pre-fileid-migration.archive.gz`.
@@ -1,73 +0,0 @@
---
title: "Rebrand miti99bot to tiennm99bot"
description: "Rename the code, repo, image, Coolify app, Mongo database and Telegram bot from miti99bot to tiennm99bot."
status: in-progress
priority: P2
effort: 1d (2h code, rest is a cutover window plus manual Telegram/Atlas steps)
branch: main
tags: [rebrand, deploy, mongo, telegram]
blockedBy: []
blocks: []
created: 2026-10-07
---
# Rebrand miti99bot → tiennm99bot
## Outcome
The project, the GitHub repo, the Coolify app, the Mongo database and the
Telegram bot are all named `tiennm99bot`. The bot runs as the new @tiennm99bot
account with all existing data. Old @miti99bot is retired after a migration
window.
Scout: [scout report](../reports/scout-261007-1423-rebrand-tiennm99bot.md).
## Decisions (user, 2026-10-07)
| Topic | Decision |
|---|---|
| Telegram bot | New @tiennm99bot account (new token). Bot usernames cannot be renamed. |
| Mongo database | Rename `miti99bot` → `tiennm99bot` by dump/restore, with a backup kept. |
| Sticker pack | New slug `stickers` → `stickers_by_tiennm99bot` (confirmed). |
| Push timing | Hold every push to `main` until the phase 2 cutover window. |
| Local checkout | Move to `/workspace/tiennm99/tiennm99bot`. |
| Coolify app | Rename the app on miti-sg and repoint it to the new repo. |
## Constraints and non-goals
- `plans/**` stays untouched: those are historical records.
- `@miti99` in `misc/handlers_test.go` is a user handle in a fixture, not the brand.
- Owner/admin IDs are Telegram *user* IDs and do not change.
- No data model changes. Chat IDs stored in Mongo stay valid under the new bot.
- Prerequisite: the uncommitted `BOT_USERNAME` change lands first as its own commit.
## Phases
| # | Phase | Who | Status |
|---|---|---|---|
| 1 | [Code rebrand](phase-01-code-rebrand.md) | agent | done (79104ef, 7008a57; not pushed) |
| 2 | [Cutover and manual steps](phase-02-cutover-and-manual-steps.md) | user + agent | B, C done 2026-10-07; D (retire old bot, drop old DB) after the migration window |
Phase 1 can merge to `main` any time, because nothing in it depends on the new
bot, repo or DB. Its deploy only changes branding strings and the default
sticker pack name, though. Hold the push until the phase 2 cutover window if
the old bot should keep its old strings until the switch.
## Acceptance criteria
- `git grep -i miti99bot -- ':!plans'` returns nothing.
- `go vet ./...`, `go test -race ./...`, `go build ./...`, the renderer
`npm run lint && npm run typecheck && npm test`, and both docker builds pass.
- Coolify app `tiennm99bot` is running:healthy from `github.com/tiennm99/tiennm99bot`.
- The logs show `bot username ... tiennm99bot`, and the owner receives
"🚀 tiennm99bot deployed: <sha>" from @tiennm99bot.
- Every collection in DB `tiennm99bot` has the same document count as in `miti99bot` at cutover.
- `/addsticker` creates or extends `stickers_by_tiennm99bot`, the loldle win/lose
stickers send, and the inline `@tiennm99bot` alias picker works.
## Open questions
- Should old @miti99bot stay alive during the migration window to answer
"moved to @tiennm99bot"? That would need a tiny separate process, so this
plan leaves it idle instead. Telegram allows only one `getUpdates` consumer
per token, so the old token is simply unused.
@@ -1,68 +0,0 @@
---
title: "Blacklist module: from brainstorm to deploy"
date: 2026-09-15
summary: "Shipped a passive per-topic blacklist/whitelist module; review caught a /help rune ceiling, four unpinned escaping sites, and a reply-chain scoping trap"
---
# Blacklist module: from brainstorm to deploy
## What happened
Delivered `internal/modules/blacklist` end to end in one session: brainstorm, plan
(`plans/260915-1108-blacklist-module/`), four phases, review, and push to `main` as `e4412d5`.
Six public commands over two per-topic lists. The module is passive by explicit decision —
it never scans chat traffic, because enforcement would need a dispatcher message hook,
BotFather privacy mode off, and per-group admin delete rights.
Four owner decisions shaped it, each settled before any code: passive registry over
auto-moderation; whitelist as an exception layer rather than an independent list; per-thread
and world-writable scope; and diacritic-sensitive matching (`ma`, `má`, `mà` are three
entries).
## What the review caught
Four things a passing test suite did not:
1. **`/help` was 9 runes from its 4096-rune pin.** Measured directly: the module cost 480 of
the 489 runes that were left. `RenderHelp` HTML-escapes both halves of every line, so each
`'` costs 5 runes and each `<text...>` costs 14. Shortening six command descriptions
restored headroom to 76. The ceiling is structural — `/help` sends one un-chunked message —
and the next module anywhere in the repo will hit it.
2. **Four surviving mutants.** Three HTML-escaping sites and the post-normalization byte cap
were correct in code but pinned by no test. Fixed, and each new test was verified to fail
with its guard removed rather than assumed to be load-bearing.
3. **Usage strings contradicted their own `Parameters` metadata** (`<text>` vs `<text...>`),
against `docs/command-parameter-conventions.md` item 3.
4. **Reply-chain thread ids.** `threadOf` trusted `msg.MessageThreadID` unconditionally.
Telegram associates a thread id with any reply chain in a supergroup, not only with a
forum topic, so a reply-form `/blacklist_add` in a plain supergroup would have written to a
scope no standalone `/blacklist_rules` could read back. Now gated on `IsTopicMessage`.
## DocStore.Scan landed mid-work
The push was rejected: four commits had landed on `main` meanwhile, one adding
`DocStore.Scan` specifically to kill the List-then-Get-per-key pattern. The phase-3 design
had copied exactly that pattern from `alias.renderNames`, and `/blacklist_rules` paid it
twice per call. Rebased and adopted `Scan` before pushing. `/blacklist_check` still uses
`List`, needing only entry names.
Worth noting for next time: a plan written against a contract can be overtaken by that
contract while the plan is being executed. The rebase was the moment to re-read what changed,
not just to resolve conflicts.
## Decisions
- Per-thread entry count stays unbounded, matching `alias`. Entries remain capped at 200
bytes each.
- `internal/modules/module.go` shows a one-space gofmt drift under go1.27.1 but is untouched
by this work and accepted by `golangci-lint`. Left alone rather than adding unrelated churn.
## Next steps
- Watch `/help` — 76 runes of headroom is one command away from red. Chunking it is a shared
surface change nobody has scoped yet.
- Confirm on the live bot that reply-form `/blacklist_add` and a later `/blacklist_rules`
agree in a non-forum supergroup.
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
@@ -1,33 +0,0 @@
---
title: "Gacha glint: additive blend and full-card coverage"
date: 2026-10-02
summary: "Mirror glint now blends additively in the card's accent tone and sweeps every corner of the card"
---
# Gacha glint: additive blend and full-card coverage
## What happened
- The user asked for an additive blend on the gacha card's mirror glint. If that cost too much GPU, they wanted lower alpha and a colour that matches the card's tone instead.
- They also noticed the glint did not cover the whole card.
## Root cause (coverage)
- `.wish-glint` was a card-sized box carrying a 135deg gradient, translated from (-100%,-100%) to (100%,100%).
- A band only paints inside its own box, so as the box slid diagonally the top-right and bottom-left corners were never inside it at the moment the band passed. With a 63:88 card, the top-right corner sits about 5px outside the box when the band reaches it.
## Changes
- `src/gacha/page/page.css`:
- The glint clip uses `mix-blend-mode: plus-lighter`.
- Band and flare colours are tinted with `--glint`, the card accent, with peak alpha around 0.75.
- The band box is now 300% of the card, centred on it, with gradient stops rescaled by 1/3. The flare width goes from 46% to 15.3%.
- `src/gacha/page/page.js`:
- `--glint` is set on the clip itself, because the card flies in an overlay outside #stage.
- The sweep now runs from translate(-26.7%) to translate(26.7%), which is 0.8 card sizes each way.
## Evidence
- Docker render timings showed additive and normal blending within noise: 14.2s vs 14.4s and 11.6s vs 11.5s per clip. Headless Chrome composites in software here, and blending costs nothing measurable.
- Frames at 3.8s (3-star) and 3.75s (5-star) show the band reaching the card edges, tinted blue and gold.
## Next steps
- The user reviews the fixtures before commit.
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
@@ -1,38 +0,0 @@
---
title: thuyvan flood alerts for Tan Thuan
date: 2026-10-02
summary: Added /thuyvan tide/rain/gauge flood view and risk-only push; renamed thoitiet to weather; shared subscription helper
---
# thuyvan flood alerts for Tan Thuan
## What happened
Added `/thuyvan` to the weather module, which was renamed from `thoitiet`. It
shows Tân Thuận's flood risk from three sources: the KTTV Nam Bộ 5-day tide
bulletin PDF (Phú An and Nhà Bè peaks), the Open-Meteo rain forecast, and VNDMS
river gauges within 30 km. `/thuyvan_subscribe` adds a 10:30 ICT push, sent
only when a peak reaches báo động I (1.40 m) or rain reaches 50 mm, with a
12:30 retry. The `lol` subscriber, claim and fan-out code moved to
`internal/modules/util/subscription`.
## Lessons
- The bulletin PDF's text layer scrambles labels ("P n A ú h"), but each
forecast row keeps its values in order after the date token. Keying on the
date token instead of X positions handles the swapped-date rows.
- In some bulletins the station label sits on its own text row, 1 pt from the
middle forecast row. All 11 real bulletins from April to October 2026 parse
after allowing for that.
- VNDMS returns 403 without a same-site Referer, and its old `dmc.gov.vn`
domains now serve a "domain moved" page.
- The review caught that a missing or stale bulletin silently turned into "no
risk". It now errors and is retried at 12:30.
## Next steps
- Check the Coolify `MODULES` value before pushing: if it lists `thoitiet`,
the bot fails at startup after the rename.
- Commit and push once that check is done.
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
@@ -1,18 +0,0 @@
---
title: Plan rebrand to tiennm99bot
date: 2026-10-07
summary: "Scouted and planned the miti99bot to tiennm99bot rebrand across code, repo, Coolify, Mongo and Telegram"
---
# Plan rebrand to tiennm99bot
## What happened
Scouted 159 files with `miti99bot`; most are the Go module path (315 import lines). External surfaces: GitHub repo, Coolify app on miti-sg (uuid ofo63lqv73huw1hce24ntg9i), Atlas DB, Telegram bot account.
## Decision
New @tiennm99bot account (bot usernames cannot be renamed), Mongo DB renamed by dump/restore, new sticker slug (proposed `stickers`), checkout moved, Coolify app renamed. Plan: plans/261007-1423-rebrand-tiennm99bot/.
## Next steps
Commit the pending BOT_USERNAME change, run phase 1 (code), then the phase 2 cutover with the user's BotFather/Atlas/Coolify steps.
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
@@ -1,72 +0,0 @@
# Brainstorm Decision: amlich Converter Improvements — Selected Scope
Date: 2026-08-18 21:58 (+07)
Basis: `plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md`
Mode: user delegated choice ("choose the best, merge if mergable").
## Decision
All three recommended items merged into one change set — they are complementary (input UX,
output honesty, test hardening), not alternatives. Rejections from the research report stand.
### In scope
1. **Leap-month hint in `/duonglich`** (highest user value, do first)
- Trigger: resolved month == that lunar year's leap month AND `nhuan` flag absent.
- Output: append "Năm nay có tháng X nhuận — thêm 'nhuan' nếu ý bạn là tháng nhuận."
- Impl: `leapMonthOf(year int) (month int, ok bool)` helper in `lunar.go` reusing
`getLunarMonth11` + `getLeapMonthOffset`; hint assembled in `handlers.go`.
- NOT triggered when `nhuan` explicit (resolves research report open question 2: no).
2. **Razor-edge caveat in both commands**
- Data: 7 disputed month-start JDs (new moons of 09/12/2072, 15/11/2077, 07/05/2130,
26/05/2150, 17/05/2159, 22/01/2175, 26/01/2199) as a package-level set in `lunar.go`,
JD values derived at implementation time and pinned by test.
- Trigger: containing month start OR next month start is in the set → append one-line
caveat: result near disputed lunar-month boundary, may differ ±1 day from future
official tables.
- Scope: months touching the boundary only, not the whole lunar year (resolves research
report open question 1: months-only; year-wide is alarmist).
- Impl: `nearDisputedBoundary(jdn int) bool` helper; handlers call it with the solar JD.
3. **Golden-table regression testdata**
- `internal/modules/amlich/testdata/lunar-years-1800-2199.txt`: one line per year —
year, leap-month index (0 = none), month lengths in order (12 or 13 entries).
- Test recomputes from engine, compares byte-exact; `-update` flag idiom regenerates.
- Rationale: round-trip test proves self-consistency only; golden table freezes the
verified boundary placement against silent drift.
### Out of scope (rejected, verified in research report)
- ΔT model update — breaks bit-compatibility with ecosystem; verified by prior 400-year diff.
- Pre-1968 historic mode — ground truth fragments (North UTC+8 1945–67; South UTC+7→UTC+8 1960).
- Table-driven engine rewrite — no authoritative source beyond current engine; test-data-only instead.
- Range extension beyond 1800–2199; can-chi day names, tiết khí, holiday lookup.
## Touchpoints
- `internal/modules/amlich/lunar.go` — `leapMonthOf`, `nearDisputedBoundary`, disputed-JD set.
- `internal/modules/amlich/handlers.go` — hint + caveat lines in both command replies.
- `internal/modules/amlich/lunar_test.go`, `handlers_test.go` — new cases; existing pins untouched.
- `internal/modules/amlich/testdata/` — new golden file.
- `docs/amlich-known-issues.md` — close open questions 1–2 with these resolutions.
## Acceptance Criteria
- All existing tests pass unchanged, incl. `knownDates` (20/6/1944, 7/7/1967) and full round-trip.
- `/duonglich 5/5/2028` (leap-5 year) shows hint; `/duonglich 5/5/2028 nhuan` and non-leap years don't.
- `/amlich` for a date inside a month adjacent to 09/12/2072 boundary shows caveat; ordinary dates don't;
`/duonglich` symmetric.
- Golden file regenerated from HEAD is byte-identical to committed version.
- ~100 LOC total incl. tests; no public-contract changes.
## Risks
- Caveat JD derivation error → wrong months flagged. Mitigation: pin the 7 JDs in a test that
recomputes them from `getNewMoonDay`.
- Reply strings are user-visible contract for handler tests — update expected strings, don't loosen asserts.
## Unresolved Questions
- None blocking. Post-leap-second timescale question stays parked in `docs/amlich-known-issues.md`
open question 4.
@@ -1,423 +0,0 @@
# 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.
@@ -1,115 +0,0 @@
# Research Report: Văn mẫu "Đơn xin lỗi T1" cho lệnh `/xlt1`
Thời điểm nghiên cứu: 2026-09-02 15:47 (+07)
## Executive Summary
Meme "đơn xin lỗi" của Việt Nam là parody **đơn từ hành chính**: giữ nguyên bố cục
công văn nhà nước (quốc hiệu, "Kính gửi", "Tôi tên là", "Nội dung sự việc",
"Tôi xin cam kết", khối ký tên) nhưng nội dung là chuyện tầm phào. Tiếng cười đến
từ *độ vênh* giữa hình thức trang trọng và nội dung vô nghĩa — không đến từ câu
chữ chửi bới. Đây là phát hiện then chốt: văn mẫu `/xlt1` phải **giống công văn
thật**, không phải một đoạn rant nữa.
Ngữ cảnh T1: cộng đồng LMHT Việt gọi T1 là "Tếu 1", dùng meme "tê liệt ngồi xe lăn"
(tồn tại từ khi SKT T1 đổi tên thành T1, cả fan lẫn anti đều dùng). Motif "trù xong
phải quay xe" là trung tâm — người viết đơn không phải anti thuần, mà là **fan đã
mất niềm tin sớm** rồi T1 lật kèo.
Khuyến nghị: `/xlt1` là **hồi tiếp nối `/ff`** đã có trong repo. `/ff` = "tắt stream
đi Tê Con ơi"; `/xlt1` = xin lỗi vì đã tắt sớm, đã trù. Cùng một nhân vật, hai thời
điểm. Cách này vừa tái dùng dàn punchline maintainer đã chốt trong `/ff`, vừa cho
lệnh mới một lý do tồn tại thay vì trùng thể loại.
## Research Methodology
- Sources consulted: 3 web_search (tiếng Việt) + đọc repo (`internal/modules/misc/ff_command.go`)
- Key terms: `"đơn xin lỗi T1" văn mẫu meme`, `"xin lỗi T1" ... "Tếu 1" Faker quay xe`, `"đơn xin lỗi" "kính gửi" T1 Faker CKTG "cam kết"`
- Giới hạn: web_search US-only; meme này sống chủ yếu trên TikTok/Facebook/Threads VN nên
không truy được một bản "văn mẫu gốc" chuẩn. Bù lại bằng bố cục đơn hành chính VN
(kiến thức ổn định) + dàn joke đã có trong repo.
## Key Findings
### 1. Bố cục "đơn xin lỗi" chuẩn (khung parody)
```
CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM
Độc lập – Tự do – Hạnh phúc
------------------
ĐƠN XIN LỖI
Kính gửi: <đối tượng>
Tôi tên là: <người viết>
Địa chỉ / Chức vụ: <...>
Nội dung sự việc: <thừa nhận sai>
Nay tôi làm đơn này để <mục đích>
Tôi xin cam kết: <1..n điều>
Kính mong <đối tượng> xem xét, tha thứ.
Tôi xin trân trọng cảm ơn!
Người làm đơn
(Ký và ghi rõ họ tên)
```
Nguồn hài: giữ **đủ** các mục này. Bỏ mục nào là mất chất công văn.
### 2. Kho joke T1 của cộng đồng Việt (đã có trong repo, tái dùng được)
Từ comment của `ffTemplate` — maintainer đã chốt các motif này:
| Motif | Nghĩa |
|---|---|
| "Tếu 1" | biệt danh chọc T1 tấu hài |
| "rạp xiếc trung ương" / "di sản văn hóa hài kịch của LCK" | T1 đá lỗi hài |
| "gói Premium hết hạn tháng 11" | T1 chỉ đỉnh vào mùa CKTG |
| "vía" | fan làm nghi thức cầu vía |
| "Tê Con" | biệt danh thân mật, chính T1 cũng dùng |
Bổ sung từ search: meme "tê liệt / ngồi xe lăn" (fan lẫn anti dùng nhiều năm).
### 3. Giọng điệu nên tránh
- Không chửi cầu thủ theo tên (`/ff` cũng cố tình tránh: "không cần soi ai riêng đâu").
- Không khẳng định số cúp / kết quả giải cụ thể → dữ kiện thay đổi theo mùa, template
tĩnh sẽ lỗi thời. Nói "sự thật đã chứng minh tôi sai" là đủ, đúng mọi mùa.
- Không toxic thật; đây là joke nội bộ nhóm chat.
### 4. Quyết định thiết kế lệnh
| Hạng mục | Chọn | Lý do |
|---|---|---|
| Tên | `xlt1` | khớp `^[a-z0-9_]{1,32}$` (`internal/modules/validate.go:10`) |
| Module | `misc` | cùng chỗ với `/ff`, `/tth` |
| Visibility | `Public` | user chốt: ai trong nhóm cũng phải làm được đơn; soi chiếu `/tth` chứ không phải `/ff` |
| Nội suy | mention người gửi ×2 | dùng lại `senderMention()` sẵn có (DRY); lấp ô "Tôi tên là" + "Người làm đơn" |
| Parse mode | HTML | mention cần thẻ `<a href="tg://user?id=...">`; giống `disclaimerCommand` |
| Tham số | không | như `/ff`, args bị bỏ qua |
## Implementation Recommendations
File mới `internal/modules/misc/xlt1_command.go`, một `const` template + một
`xlt1Command()`; đăng ký trong `New()` ngay sau `ffCommand()`. Test soi chiếu
`TestFF_*` trong `handlers_test.go`; cập nhật map trong
`TestNew_RegistersExpectedCommands`; cập nhật bảng module ở `README.md:11`.
### Common Pitfalls
- Template chèn qua `ReplyHTML` → tuyệt đối không để `<`, `&`, `>` thô trong văn mẫu,
không thì Telegram trả 400. Dùng `–`, `oOo`, `...` thay vì `<...>`.
- `senderMention` đã `html.EscapeString` phần tên; target/prose tĩnh thì tự lo.
## Resources & References
- [Bộ sưu tập meme xin lỗi](https://yeuvanhoc.edu.vn/meme-xin-loi/) — thể loại "đơn xin lỗi" như công văn parody
- [Mẫu Đơn Xin Lỗi Meme](https://theselfishmeme.co.uk/don-xin-loi-meme) — biến thể bố cục
- [Threads: meme tê liệt xe lăn T1](https://www.threads.com/@ghuyph_/post/DQtlGBYj-gm/) — lịch sử meme, fan lẫn anti dùng
- [Faker – chức vô địch thứ 4](https://bytuanhuynh.substack.com/p/faker-chuc-vo-ich-thu-4-inh-cao-thich) — motif "hết thời rồi lật kèo"
- Repo: `internal/modules/misc/ff_command.go` — dàn punchline đã chốt
## Unresolved Questions
1. ~~`Protected` hay `Public`?~~ Đã chốt `Public` (2026-09-02).
2. Có cần alias (`/xinloit1`)? Hiện chỉ làm đúng `/xlt1` như yêu cầu.
@@ -1,129 +0,0 @@
# /giaxang — Vietnam retail fuel price source and design
Researched: 2026-09-30 11:27 (Asia/Saigon)
## Outcome
Use Petrolimex's own CMS JSON endpoint. It returns all six retail products with
Zone 1 and Zone 2 prices in one unauthenticated GET, was verified live from this
Oracle ARM64 host with plain `curl` (no special headers), and its numbers match
today's press reports (E10 RON 95-III 27,080 / 27,620 đ, effective 2026-09-24).
## Source evaluation
| Source | Verdict | Evidence |
|---|---|---|
| Petrolimex CMS JSON (`portals.petrolimex.com.vn/~apis/portals/cms.item/search`) | **Use** | Live 200 response, 6 items, `Zone1Price`/`Zone2Price` numeric, `LastModified` per item. First-party data. |
| VNAppMob (already used by `gold`) | Not available | Docs list only Province, Gold v2, Exchange rate v2; `/api/v2/petrol*` returns 404. |
| petrolimex.com.vn HTML | Reject | Price table is rendered client-side (jQuery + crypto-js + rsa.js); nothing to parse server-side. |
| Third-party aggregators (vietfuel-api, toanqng/fuel JSON on GitHub) | Reject as primary | Extra hop on someone's hobby infrastructure that itself scrapes Petrolimex. |
| News sites (thanhnien, baomoi) | Reject | HTML scraping, unstable markup. |
## Endpoint contract
```text
GET https://portals.petrolimex.com.vn/~apis/portals/cms.item/search
?object-identity=search
&x-request=<base64 JSON filter>
```
The `x-request` value is base64 of a fixed filter (SystemID
`6783dc1271ff449e95b74a9520964169`, RepositoryID
`a95451e23b474fe5886bfb7cf843f53c`, RepositoryEntityID
`3801378fe1e045b1afa10de7c5776124`, `Status=Published`, sorted by
`LastModified` descending). Build it from a JSON literal in code rather than
pasting an opaque base64 blob, so the IDs stay readable.
Fields used from each `Objects[]` entry:
| Field | Example | Use |
|---|---|---|
| `Title` | `Xăng E10 RON 95-III` | Product label |
| `Zone1Price` | `27080` | VND/liter, vùng 1 |
| `Zone2Price` | `27620` | VND/liter, vùng 2 |
| `DIsplayOrder` (sic) | `4` | Sort order |
| `LastModified` | `2026-09-24T07:47:51.496Z` | "Áp dụng từ" timestamp (max over items) |
Current product list: Xăng E10 RON 95-V, Xăng E10 RON 95-III, Xăng E5 RON 92-II,
DO 0,001S-V, DO 0,05S-II, Dầu hỏa 2-K. Render whatever titles the API returns;
do not hardcode the list (it changed with the 2026 E10 rollout).
## Brainstorm contract
- **Outcome:** `/giaxang` in the `misc` module replies with the current
Petrolimex retail price table (all products, both zones, VND/liter) plus the
effective timestamp in Asia/Saigon time.
- **Constraints:** Go, follow existing `misc`/`gold` patterns; HTTP timeout under
the handler deadline via `chathelper.FetchContext`; https-only endpoint with a
test-overridable base URL (`httptest`); command metadata per
`docs/command-parameter-conventions.md`; update README module table.
- **Non-goals:** price history, change alerts/cron, PVOil or other retailers,
per-province lookup, storing prices in MongoDB.
- **Acceptance criteria:** handler test against an `httptest` server asserts the
exact rendered text; upstream failure (non-200, bad JSON, empty list) yields a
friendly Vietnamese error reply, not silence; registration/help tests pass;
`go test ./...`, `go vet ./...`, `golangci-lint run` clean.
## Trade-offs
| Approach | Load-bearing assumption | Fails first when |
|---|---|---|
| A. Fetch Petrolimex on every call (recommended) | Endpoint stays public and unauthenticated | Petrolimex adds auth/encryption to `~apis` or changes the IDs |
| B. Fetch + in-memory cache (e.g. 10 min) | Same as A | Same as A; also adds staleness logic for little gain at chat volume |
| C. Third-party aggregator JSON | Hobby project stays maintained | Maintainer stops updating (toanqng last push 2026-09-24, vietfuel 2026-07-16) |
Recommend A. Prices change at most weekly (Thursday adjustments), traffic is
chat-scale, and the fetch is one small GET, so a cache is not needed yet.
Better approaches: none — recommended direction is the requested one (first-party
JSON endpoint verified live).
## Proposed reply format
```text
⛽ Giá bán lẻ xăng dầu Petrolimex
Áp dụng từ 15:00 24/09/2026
Xăng E10 RON 95-V 28.080 | 28.640
Xăng E10 RON 95-III 27.080 | 27.620
Xăng E5 RON 92-II 26.390 | 26.910
DO 0,001S-V 32.090 | 32.730
DO 0,05S-II 30.490 | 31.090
Dầu hỏa 2-K 30.020 | 30.620
đ/lít — Vùng 1 | Vùng 2
```
Render inside `<pre>` (HTML) so columns align. Note: `LastModified` is when
Petrolimex edited the record (07:47Z = 14:47 ICT), which is shortly before the
official 15:00 effective time — label it "Cập nhật" rather than claiming the
exact effective time.
## Risks
- Unofficial API: Petrolimex may change IDs or add request signing (their site
already loads rsa.js/crypto-js). Mitigation: clear error reply + log; the
source lives behind one small client so swapping is cheap.
- Petrolimex reportedly blocks some cloud IP ranges (Google). Verified working
from this Oracle host; the production Coolify host should be checked once
after deploy.
## Next steps
1. Add `misc/giaxang_command.go` (client + formatter + command) and register it
in `misc.New`.
2. Add handler tests with `httptest`; update README module row and package doc.
3. Run tests/vet/lint, commit `feat(misc): add /giaxang retail fuel price`.
## Sources
- Petrolimex endpoint (live probe, 2026-09-30)
- [chiraitori/vngasprice](https://github.com/chiraitori/vngasprice) — Go scraper that documents the endpoint
- [thanhnien.vn — Giá xăng dầu hôm nay 25.9.2026](https://thanhnien.vn/gia-xang-dau-hom-nay-2592026-xang-e10-len-muc-cao-nhat-28640-dong-lit-185260925083720959.htm) — cross-check of prices
- [TranQui004/vietfuel-api](https://github.com/TranQui004/vietfuel-api), [toanqng/fuel](https://github.com/toanqng/fuel) — aggregator alternatives
- [VNAppMob Open API docs](https://api.vnappmob.com/) — no fuel endpoint
## Unresolved questions
- Visibility: public (like `/random`) or protected? Recommended: public.
- Show both zones, or Zone 1 only for a shorter reply? Recommended: both.
@@ -1,123 +0,0 @@
# Research + Brainstorm: `thoitiet` weather module
Conducted 2026-10-01. Scope: weather data source and command design for a new
`thoitiet` module with today, tomorrow, and this-week forecasts, an optional
location argument, and Ho Chi Minh City as the default.
## Recommendation
Build a new `internal/modules/thoitiet` module on **Open-Meteo** (forecast +
geocoding). It needs no API key, no new env var, and no storage. All payload
fields below were verified with live requests on 2026-10-01.
## Commands
| Command | Parameters | Behaviour |
|---|---|---|
| `/thoitiethomnay` | `[location...]` | Current conditions + today's forecast |
| `/thoitiet` | `[location...]` | Alias of `/thoitiethomnay` (same handler) |
| `/thoitietngaymai` | `[location...]` | Tomorrow's daily forecast |
| `/thoitiettuannay` | `[location...]` | One line per day for the next 7 days, starting today |
`[location...]` follows `docs/command-parameter-conventions.md` (optional
remaining text, so `/thoitiet Đà Lạt` works). An empty argument means Ho Chi
Minh City. The alias is registered as a second `modules.Command` sharing the
handler, the same pattern as `/trongtruonghop` + `/tth` in `misc`. Stats will
count the two names separately, which matches that existing alias.
## Data source comparison
| Source | Key | Free limit | VN geocoding | Verdict |
|---|---|---|---|---|
| Open-Meteo | None | 10,000/day, 5,000/h, 600/min, non-commercial | Yes, `language=vi` returns Vietnamese names | **Pick** |
| OpenWeatherMap | Required | 1,000/day (One Call 3.0) | Yes | Needs secret + card on file |
| WeatherAPI.com | Required | 1M/month | Yes, `lang=vi` | Needs secret; no benefit here |
A personal Telegram bot is non-commercial and nowhere near the limits, so
Open-Meteo is fine without caching. Its data is CC BY 4.0, so replies end with a
short `Nguồn: Open-Meteo` credit.
## Verified API behaviour
Geocoding: `GET https://geocoding-api.open-meteo.com/v1/search?name=<q>&count=10&language=vi`
| Query | Result |
|---|---|
| `Ho Chi Minh`, `Hồ Chí Minh` | Thành phố Hồ Chí Minh (10.823, 106.630) |
| `Ha Noi`, `Hanoi` | Hà Nội |
| `Da Lat` | Ðà Lạt |
| `Đà Lạt` (typed with diacritics) | **Wrong: "Đã Tịch", Quảng Trị** |
| `Da Nang`, `Can Tho`, `Nha Trang`, `Hue`, `Vung Tau`, `Tokyo` | Correct |
| `hcm`, `Thu Duc` | No results |
| `Saigon` | A ward in Bình Thạnh, not the city |
Two consequences for the design. First, strip Vietnamese diacritics (including
`Đ/đ` → `D/d`) before querying, because GeoNames stores Đà Lạt with the
look-alike `Ð` (U+00D0) and diacritic input misses it. Second, keep a small
alias map for common shorthand: `hcm`, `tphcm`, `sg`, `saigon`, `sai gon` → HCM;
`hn` → Hà Nội; `dn` → Đà Nẵng. Pick the first `country_code=VN` result, else the
first result, so foreign cities still work in a single request.
Forecast: `GET https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&timezone=auto&forecast_days=7`
with
- `current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m`
- `daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunrise,sunset`
`timezone=auto` makes "today" the location's own date. HCM returns
`utc_offset_seconds=25200`. Weather is a WMO code that maps to a Vietnamese
label plus emoji (about 28 codes, for example 0 Trời quang ☀️, 3 Nhiều mây ☁️,
61–65 Mưa 🌧️, 80–82 Mưa rào 🌦️, 95–99 Dông ⛈️).
## Design
```text
handler(range) ── parse args ──► resolveLocation(q)
├─ empty → HCM constant (no geocoding call)
├─ alias map → constant
└─ geocode(stripDiacritics(q))
──► fetchForecast(lat, lon) ──► format(range) ──► send HTML
```
Files, mirroring `lol` and `giaxang`:
- `thoitiet.go`: `New(deps)` and the four command registrations.
- `api_client.go`: geocode + forecast with an `*http.Client` timeout,
`io.LimitReader`, and package-level URL vars so tests can use `httptest`.
- `location.go`: diacritic stripping, alias map, result selection.
- `format.go`: WMO table and the today/tomorrow/week renderers.
- `handlers.go` and tests for each file.
- Wiring: `cmd/server/main.go` catalog entry `"thoitiet": thoitiet.New`, the
expected `cmd/server/command_menu_test.go` entries, and the README module table.
Sample `/thoitiet` reply:
```text
🌤 Thời tiết hôm nay — Thành phố Hồ Chí Minh
Hiện tại: 29°C (cảm giác 35.7°C), Nhiều mây ☁️, độ ẩm 78%, gió 3 km/h
Cả ngày: 24–33°C, Mưa rào 🌦️, khả năng mưa 70% (5.3 mm), UV 8.9
Mặt trời: 05:42 – 17:44
Nguồn: Open-Meteo
```
Errors: unknown location → `Không tìm thấy địa điểm "<q>".`; upstream failure →
`Không lấy được dữ liệu thời tiết. Thử lại sau nhé.` (same tone as `/giaxang`).
## Rejected options
- Caching forecasts in Mongo: the limits make it unnecessary, and it adds a
storage schema for no user-visible gain.
- A per-user saved default location: not requested.
- A keyed provider (OWM, WeatherAPI): adds a secret and setup for no gain.
## Next steps
1. Write the plan under `plans/261001-…-thoitiet-weather-module/` and implement.
2. Run `go test ./internal/modules/thoitiet/... ./cmd/server/...`, `go vet ./...`,
and `golangci-lint run`.
## Decisions
- `/thoitiet` is an alias of `/thoitiethomnay` (user request, 2026-10-01).
- `/thoitiettuannay` covers the next 7 days starting today, not the Mon–Sun
calendar week (user choice, 2026-10-01).
@@ -1,63 +0,0 @@
# Research: escalating the gacha wish animation by tier
Conducted 2026-10-01. Target: `tiennm99/wheelofnames` `src/remotion/GachaComposition.jsx`.
## Outcome
Genshin signals rarity mainly through **colour, size, and how much the screen
is taken over**, not through different choreography. Each tier keeps the same
beats (meteor, flash, reveal, star pops), and the higher tier gets more of
everything: a bigger, longer meteor; a halo; more particles; a stronger flash;
and a richer reveal. Our current composition already follows the beats but
scales almost nothing except colour, which is why 3★, 4★, and 5★ feel the same.
## How the source game escalates
| Beat | 3★ | 4★ | 5★ |
|---|---|---|---|
| Meteor | Blue, thin trail | Purple, brighter, wider trail | Gold, largest, longest trail |
| Tell before landing | none | none | rainbow-like ring forms around the star ([community report](https://genshin-impact.fandom.com/f/p/4400000000000309937)) |
| Screen | night sky | night sky | gold light floods the screen, cascade of sparkles ([overview](https://img.krmangalam.edu.in/star-base/genshin-impact-5-star-wish-animation-secrets-1764806225)) |
| Reveal | plain item card | character/weapon reveal with streaks | same, plus the strongest glow and the longest build-up |
| Stars | pop in gold, one at a time | same | same; the count itself is the payoff |
The fandom wiki page for Wish was not reachable (HTTP 402), so the table above
also relies on well-known gameplay behaviour; treat the 4★ "brighter trail"
row as observed convention, not documented spec.
## Techniques that fit our renderer
The renderer draws DOM/CSS frames in Chrome headless and encodes H.264.
Measured cost today is about 6.6–6.9 s per 7 s clip, against a 15 s
production timeout, so there is headroom but not unlimited headroom.
- **Scale the existing layers by tier** (cheapest, biggest effect): meteor head
size, trail length and sample count, spark count, ray opacity, mote count.
- **Halo ring for 5★** on the meteor before landing, drawn as a
`conic-gradient` rainbow ring with a radial mask, matching the in-game tell.
- **Screen flood for 5★**: tint the sky gold as the meteor nears the ground and
make the flash longer and warmer.
- **Camera shake** on impact for 4★ and 5★: decaying `translate` on the whole
frame; this is a standard impact device (Remotion templates ship one, see
[remotion-templates](https://github.com/reactvideoeditor/remotion-templates)).
- **Shockwave rings**: one ring for 3★, two for 4★, three plus a starburst for
5★.
- **Rank letter emblem** (`B`/`A`/`S` per the user's request) instead of the
label's first character, with a heavier frame and a sheen sweep at 5★.
- **Falling sparkle rain** behind the 5★ reveal ("raining stars").
`@remotion/effects` (glow, lightTrail, starburst; from v4.0.464,
[docs](https://www.remotion.dev/docs/effects/api)) is not installed and applies
only to specific Remotion components. Adding it would be a new dependency for
effects we can already draw with gradients, so it is not recommended.
## Recommendation
Keep one composition and drive every effect from a per-tier "intensity" table
in `gacha-timeline.js`, so the escalation is data, testable, and tunable.
Re-measure render time after the change; keep it under about 10 s.
## Unresolved questions
- None blocking. The exact 4★ visual delta in the source game is convention,
not documented.
@@ -1,59 +0,0 @@
# Research: a realistic gravity fall for the gacha meteor
Conducted 2026-10-01. Target: `tiennm99/wheelofnames` `src/remotion/gacha-timeline.js`
(`getMeteorState`) and `GachaComposition.jsx`.
## Outcome
Users said the fall did not look good ("Cái bay xuống chưa đẹp"). The cause:
the meteor moved along a fixed quadratic Bézier with a quadratic ease-in on
the curve parameter. That is not motion under gravity: it starts almost still,
whips to the end, and its trail was spaced along the curve instead of in time,
so it bunched up early and showed no sense of speed.
The fix is plain Newtonian projectile motion, which the sources agree is the
right model: constant horizontal speed, vertical speed growing linearly under
gravity, which traces a true parabola
([Wikipedia: projectile motion](https://en.wikipedia.org/wiki/Projectile_motion),
[GameDev.net](https://www.gamedev.net/forums/topic/629786-projectile-motion-parabola/4970539/)).
## Formula
With launch point `P0`, impact point `P1`, fall duration `T`, and a chosen
launch slope `k` (vertical speed as a fraction of horizontal speed):
```text
vx = (x1 - x0) / T
vy0 = |vx| * k k = -0.3: launches slightly upward
g = 2 * (y1 - y0 - vy0 * T) / T² solved so it lands exactly at P1
x(t) = x0 + vx * t
y(t) = y0 + vy0 * t + ½ g t²
vy(t) = vy0 + g t
```
Solving `g` from the endpoints keeps the impact point and timing fixed, so
the flash and reveal choreography is unchanged. With `k = -0.3` the meteor
climbs at about 17°, bends over, and dives at about 54° while speeding up.
## Supporting techniques
- **Motion streak sampled in time:** the trail draws the meteor's own past
positions every 16 ms, so its length grows with speed, which is how motion
blur reads ([Wikipedia: motion blur](https://en.wikipedia.org/wiki/Motion_blur_(media))).
- **Velocity-aligned stretch:** the head is rotated to `atan2(vy, vx)` and
stretched along it while thinning across it to keep its area, the standard
squash-and-stretch for fast objects
([Programmatic squash and stretch](http://www.alexgalbraith.nz/2019/04/05/programmatic-squash-and-stretch/)).
- **Spark physics:** each spark inherits part of the meteor's velocity plus a
random kick, then falls under its own gravity while fading.
## Verification
A time-lapse of rendered frames shows a clear parabolic arc with gaps that
widen as the meteor accelerates. Tests check constant horizontal speed,
constant vertical acceleration, the exact landing point, and the climb-to-dive
angles. Render time is unchanged (about 9 s for 5★, 7 s for 3★/4★).
## Unresolved questions
- None.
@@ -1,69 +0,0 @@
# Research: matching the gacha meteor to Genshin Impact's motion
Conducted 2026-10-01. Target: `tiennm99/wheelofnames` `getMeteorState` in
`src/remotion/gacha-timeline.js` and the trail in `GachaComposition.jsx`.
## Outcome
Genshin's meteor does **not** follow a gravity arc. Measured from reference
footage, it sweeps in along a **straight line** from the upper left at about
23° below horizontal and **decelerates exponentially** to a near hover just
above the horizon, where the colour reveal and the 5★ rainbow ring play. The
gravity parabola shipped earlier was replaced with this measured curve.
## Method
Reference: the single-pull wish videos (`3star-single.mp4`,
`4star-single.mp4`, `5star-single.mp4`; 1920x1080, 60 fps, 6 s) from the
public fan simulator
[AguzzTN54/Genshin-Impact-Wish-Simulator](https://github.com/AguzzTN54/Genshin-Impact-Wish-Simulator).
They were used only to measure motion; nothing from them is shipped. Frames
were extracted with ffmpeg at 10 fps (timeline) and 30 fps (glide), and the
head was tracked as the brightest cluster above the cloud line.
## Findings
Timeline of the in-game single pull: cloud portal (0–1.2 s), dive with a beam
sweep (1.2–1.6 s), **glide and hover** (1.7–3.4 s), flash (3.5 s), a streak and
a near-vertical drop, then the result card.
Head position during the glide (5★, fraction of frame):
| t (s) | 1.80 | 2.00 | 2.20 | 2.40 | 2.80 | 3.20 |
|---|---|---|---|---|---|---|
| x | 0.485 | 0.523 | 0.547 | 0.549 | 0.535 | 0.559 |
| y | 0.509 | 0.531 | 0.540 | 0.536 | 0.543 | 0.560 |
- **Path:** straight, about 23° below horizontal, from off-screen upper left to
about (0.56, 0.56).
- **Easing:** exponential ease-out. About 88% of the distance is covered 0.5 s
after entry, then the head creeps and hovers; rate `k ≈ 4.2 /s`.
- **Trail:** a long straight beam from beyond the frame edge that narrows into
the head and stays full length while the head hovers; several ribbons fan
out along it, with sparkles floating above it.
- **Colour:** every tier starts blue. The 4★ beam turns purple around 2.5 s;
the 5★ head turns gold around 2.8 s and the rainbow ring forms by 3.1 s.
## Formula now used
```text
s(t) = D * (1 - e^(-k t)) / (1 - e^(-k T)) k = 4.2, T = 2.2 s
p(t) = hover - u * (D - s(t)) u = (cos 23°, sin 23°)
v(t) = u * D * k * e^(-k t) / (1 - e^(-k T))
```
`D` reaches from 15% beyond the left edge to the hover point (0.55, 0.55);
normalising by `1 - e^(-kT)` lands the head exactly on the hover point.
## Not adopted
- **Blue-first colour reveal** (all tiers start blue, then change): this is
the signature suspense beat in Genshin, but it changes how the tiers read,
so it is left as an option for the owner.
- The cloud portal, beam dive, and final vertical drop do not fit the 7 s
clip without restructuring the timeline.
## Unresolved questions
- Should the meteor start blue for every tier and reveal its colour during
the hover, as Genshin does?
@@ -1,217 +0,0 @@
# Research + Brainstorm: `/thuyvan` flood alerts for Tân Thuận
Conducted 2026-10-02. Scope: flood (ngập lụt) alerts for Tân Thuận, Quận 7,
TP.HCM, through a `/thuyvan` command and an opt-in daily push. Both go into the
current weather module, which is renamed from `thoitiet` to `weather`.
## Recommendation
Flooding in Tân Thuận is driven mostly by high tides (triều cường) on the Sài
Gòn and Đồng Điền rivers, and sometimes by heavy rain. The nearest gauges are
Phú An (2.9 km) and Nhà Bè (6.8 km). Both have official alarm levels (báo động,
BĐ) of BĐ I 1.40 m, BĐ II 1.50 m and BĐ III 1.60 m. The design uses three
sources:
1. **Tide forecast:** the official 5-day tide bulletin from Đài KTTV Nam Bộ, a
daily PDF with the peak forecast at Phú An and Nhà Bè. This is the alert
trigger.
2. **Rain forecast:** the Open-Meteo daily `precipitation_sum` at Tân Thuận,
the client the module already has. This is the second trigger.
3. **Live levels:** VNDMS readings for the stations near Tân Thuận, with their
alarm tier. This is context, and the fallback when the bulletin is missing.
An alert fires when a forecast tide peak at Phú An or Nhà Bè is at least
**1.40 m** (BĐ I), or when the day's rain forecast is at least **50 mm**.
## Decisions (user, 2026-10-02)
- Goal: flood (ngập lụt) alerts at Tân Thuận, not a general water-level tool.
- Delivery: a `/thuyvan` command plus an opt-in daily push
(`/thuyvan_subscribe`, `/thuyvan_unsubscribe`).
- Trigger: a tide peak at or above BĐ I, OR rain of 50 mm or more.
- `/thuyvan` takes **no parameter**. It always shows Tân Thuận: the flood
forecast plus the live levels at nearby stations.
- Placement: inside the weather module, renamed `thoitiet` → `weather`.
- Rejected: Open-Meteo tide modelling and a `[location...]` argument.
## Sources (all verified live on 2026-10-02)
### Tide bulletin: KTTV Nam Bộ
- **Discovery.** The `https://kttvnb.vn/` homepage links today's article, for
example
`/index.php/100-thong-tin-kttv/thuy-van/29194-ba-n-tin-da-ba-o-tha-y-v-n-tphcm-ra-nga-y-02-10-2026`.
The slug varies (one used `khu-va-c-tphcm`), so match
`(\d+)-[^"]*tphcm-ra-nga-y-(\d{2})-(\d{2})-(\d{4})` and take the newest date.
The `thuy-van` category page is not sorted by date, so do not use it.
- **PDF.** The article page links
`https://kttvnb.vn/attachments/article/<id>/HCMC_TVHN_YYYYMMDD.pdf` (318 KB,
issued at about 09:24 ICT). The PDF path needs the article ID, so it cannot
be guessed from the date. Plain `http://kttv-nb.org.vn` also serves the files,
but its TLS certificate is broken, so use `https://kttvnb.vn`.
- **Text extraction** with `github.com/ledongthuc/pdf` (`GetTextByRow`, pure
Go) works. The labels come out with odd letter spacing (`P h ú An`), but the
forecast number rows are clean and come in a fixed order: 5 rows for Phú An,
5 for Nhà Bè, then 5 for Thủ Dầu Một.
```text
02/ 10 1.33 07.00 1.29 21.00 - 1.79 15.00 - 0.04 01.00
```
Each row is the date, peak 1 (height and time), peak 2, trough 1 and
trough 2. `ct` means no second peak that day. Minus signs may be split from
the number (`- 1.79`) or appear after it (`1.78 -`). Only the peaks are
needed, and they are always positive, so the parser can drop the trough
columns.
- The same PDF prints the BĐ I/II/III thresholds (1.40/1.50/1.60 m), and VNDMS
`detailRain` confirms them for both stations.
- Today's forecast is a peak of 1.33 m at Phú An on 02/10, falling to 0.68 m by
06/10, so no alert. The 28–30/09 peaks reached 1.56–1.60 m, BĐ II–III.
### Live levels: VNDMS
- `GET https://vndms.gov.vn/water_level?lv=0` needs a
`Referer: https://vndms.gov.vn/` header, or it returns 403. It gives all 458
stations as GeoJSON, with the fields inside `popupInfo` HTML (name, code,
province, river, and `Mực nước (1.33(m) 7-02/10)` or `Không có số liệu`).
`lv=1..3` returns only the stations at that alarm tier.
- The response is 32 KB gzipped (432 KB decoded) and takes 0.2–1.2 s.
- Reporting stations within 30 km of Tân Thuận today: Phú An 2.9 km, Nhà Bè
6.8 km, Biên Hòa 25.0 km, Thủ Dầu Một 26.1 km and Bến Lức 29.1 km.
### Rain: Open-Meteo
The module's existing `fetchForecast` already returns daily `precipitation_sum`
and `precipitation_probability_max`. Call it with a fixed Tân Thuận place.
### Tân Thuận point
Open-Meteo geocoding gives `Tân Thuận`, Quận Bảy, at
`10.74111, 106.71806`. Store it as a constant, as `hcmPlace` is stored, because
geocoding "Tan Thuan" by name returns 14 Vietnamese places.
## Design
```text
/thuyvan ─┬─ tide bulletin (homepage → article → PDF → peak rows)
├─ Open-Meteo forecast @ Tân Thuận (rain, days 0..4)
└─ VNDMS lv=0..3 (stations ≤ 30 km, sorted by distance)
─► merge into a per-day risk view ─► Reply
cron 10:30 ICT ─► same fetch ─► any day at risk? ─► push to subscribers
no ─► send nothing
```
- **Fetching.** The three sources run concurrently under one fetch context.
Each one fails on its own: the reply omits a failed source with a short note
and never shows guessed numbers. If the bulletin is missing (for example
before 09:24) or fails to parse, the reply uses yesterday's bulletin when the
homepage still links it, labelled with its issue date, or else shows only
the live levels and the rain.
- **Parsing safety.** A bulletin counts as valid only if it yields exactly 5
dated rows for each of Phú An and Nhà Bè, with peaks between 0 and 3 m.
Anything else is a parse failure, logged with the PDF URL. Tests use the
captured `HCMC_TVHN_20261002.pdf` as a fixture.
- **Risk per day.** Take the highest of the two peaks at Phú An and Nhà Bè and
map it to a tier: below BĐ I, BĐ I, BĐ II or BĐ III. Rain of 50 mm or more
flags the day too. Rain forecasts beyond about 5 days are not reliable, so
only the bulletin's 5 days count.
- **Push.** A cron at 10:30 ICT, after the bulletin is issued, sends one
message per subscribed (chat, topic) only when at least one of the next 5
days is at risk. It needs no dedupe state, because it fires once a day.
Subscribers are stored like `lol`'s subscriber list, including the pruning
of chats that blocked the bot. `lol/subscribers.go` and its pruning code
would then have a second user, so move them to a shared
`internal/modules/util` package instead of copying them.
Sample `/thuyvan` reply:
```text
🌊 Thuỷ văn — Tân Thuận (Q.7)
Dự báo đỉnh triều (Phú An / Nhà Bè):
02/10: 1.33 / 1.34 m (07:00) — dưới BĐ I
03/10: 1.18 / 1.19 m (08:00)
04/10: 1.01 / 1.03 m (09:00)
05/10: 0.84 / 0.88 m
06/10: 1.13 / 0.70 m
Mưa hôm nay: 5.3 mm (70%)
Mực nước hiện tại:
Phú An (Sài Gòn, 2.9 km): 1.33 m, 7h 02/10
Nhà Bè (Đồng Điền, 6.8 km): 1.18 m, 7h 02/10
⚠️ Thủ Dầu Một (Sài Gòn, 26.1 km): 1.56 m, 7h 02/10 — BĐ II
BĐ I/II/III: 1.40 / 1.50 / 1.60 m
Nguồn: Đài KTTV Nam Bộ, VNDMS, Open-Meteo
```
Sample push, sent only on risk days:
```text
⚠️ Cảnh báo ngập — Tân Thuận (Q.7)
29/09: đỉnh triều 1.60 m lúc 17:00 tại Nhà Bè — BĐ III
30/09: đỉnh triều 1.56 m — BĐ II
Mưa 29/09: 62 mm
Nguồn: Đài KTTV Nam Bộ, Open-Meteo
```
## Changes
- Rename `internal/modules/thoitiet` to `internal/modules/weather`: the
package, its doc, the log `module` field, and the `cmd/server/main.go` import
and catalog key `"weather"`.
- The module gains storage for its subscriber list, in the `weather`
collection, with no migration. Add the `CollectionName` constant the way the
other modules with storage do.
- New files: `flood.go` (command, cron and risk), `tide_bulletin.go`
(discovery and PDF parsing), `water_level.go` (VNDMS), plus tests and
fixtures.
- Add the dependency `github.com/ledongthuc/pdf`. It has no tagged releases,
so `go get @latest` records a pseudo-version.
- Add three public commands: `thuyvan`, `thuyvan_subscribe` and
`thuyvan_unsubscribe`. Update `cmd/server/command_menu_test.go` and the
README module row.
- **Breaking:** `modules.Build` rejects unknown module names, so if Coolify's
`MODULES` lists `thoitiet`, the bot **fails to start** after the rename.
Check that value before pushing. Commit the rename on its own as
`refactor(weather)!:`.
## Risks
| Risk | Mitigation |
|---|---|
| The bulletin layout changes and breaks the PDF row parsing | Strict row-count and range check, fallback to live levels, a logged URL, fixture tests |
| The homepage drops the article link or changes the slug | Loose regex; a missing link means "no bulletin" and the fallback |
| The VNDMS Referer requirement tightens, or the domain moves again (it moved in 2025–26) | URL var, a clean failure note in the reply |
| 50 mm/day misses short cloudbursts (40 mm in an hour floods streets too) | Use the daily total for v1; the hourly maximum is a follow-up if alerts prove too quiet |
| The bulletin's 5 days end before a long high-tide spell | Each day's push carries the next 5 days |
## Changes after review (2026-10-02)
- A bulletin issued more than a day before today counts as missing, because
a stalled homepage would otherwise produce a valid-looking bulletin whose
dates no longer cover the window.
- If the bulletin is missing and the rain forecast shows no risk, the push
returns an error instead of reporting "no risk", since tide is the main
signal.
- A 12:30 ICT retry cron reruns the push. The day is claimed only when an
alert goes out, so the retry sends nothing after a successful 10:30 push.
- `/thuyvan` notes a failed rain forecast as well as a failed bulletin or
gauge list.
- Known limit: `/thuyvan` is not cached, so each call makes 8 upstream
requests. That is acceptable at this bot's usage; add a short cache if VNDMS
starts rate-limiting.
## Next steps
1. Check the Coolify `MODULES` value for the rename.
2. Write the plan under `plans/261002-1205-thuyvan-flood-alert/` with phases:
the rename, moving the subscriber code to a shared package, the parsers and
fixtures, then the command, cron and push.
3. Run `go test ./internal/modules/... ./cmd/server/...`, `go vet ./...` and
`golangci-lint run`.
## Unresolved questions
- Push time 10:30 ICT: is that fine, or do you want a second, evening push
before the evening high tides?
- Should the push go out only when there is risk (as designed), or every day
as a summary?
@@ -1,128 +0,0 @@
# Research + Brainstorm Report: Improving the amlich/duonglich Converter
Date: 2026-08-18 21:47 (+07)
Scope: `internal/modules/amlich` — what improvements remain, ranked; what to explicitly reject.
Inputs: repo code + `docs/amlich-known-issues.md` (prior 400-year Meeus-vs-HND diff), 5 web lookups (2 WebSearch, 3 WebFetch).
## Executive Summary
Algorithm question is settled and should stay settled: the truncated Hồ Ngọc Đức port is bit-compatible
with the de-facto Vietnamese ecosystem and empirically beat a full Meeus ch.49 engine on both
historically verifiable razor-edge dates. No engine change is justified by any new evidence found.
Remaining improvement space is small and mostly UX/honesty, not math:
(1) caveat line for the 7 razor-edge lunations 2072+, (2) leap-month ambiguity hint in `/duonglich`,
(3) optional golden-table regression hardening. Everything else researched — ΔT model update,
pre-1968 historic mode, table-driven rewrite, range extension — should be rejected; reasons below.
## New Research Findings (2026)
- **ΔT trend confirms doc's suspicion, changes nothing.** Earth set rotation-speed records in 2024–2025;
IERS added no leap second in 2024; ~30% chance of a first-ever *negative* leap second before 2035.
ΔT is flat-to-declining vs the polynomial's predicted growth — so the code's ΔT branch overestimates
future ΔT. But this only matters inside the already-documented razor-edge windows. See "Reject: ΔT".
- **Leap-second abolition adds a *new* far-future uncertainty.** CGPM votes Oct 2026 to replace the leap
second (possibly retiring it as early as 2027). If UTC stops tracking UT1, civil UTC+7 slowly drifts
from the astronomical time the algorithm models — same bucket as ΔT: only razor-edge relevant,
unresolvable until Vietnam's authorities say which timescale the calendar follows.
- **No official Vietnamese tables exist past ~2100.** Nothing from a state calendar bureau covering
2072+ disputes surfaced. Hồ Ngọc Đức remains the de-facto ground truth; his site claims historic
reliability "since 1301" and notes official/astronomical calendars coincide since 1976.
- **Pre-1968 is messier than "UTC+8".** Per HND's historic-calendar page: North used UTC+8 1945–67;
South used UTC+7 until 1959 then UTC+8 1960–67; pre-1945 rests on dynastic tables (Bách trúng kinh
1624–1799, Khâm định vạn niên thư 1554–1903). A single "UTC+8 historic mode" (open question 2 in
known-issues) would be *wrong for South Vietnam 1955–59* — the idea is even weaker than documented.
- **Go ecosystem check.** Other Go ports (hungtrd/amlich, buichuongvnua/amlich, go-dyn/vcalendar) are
straight HND ports without the day-0 overshoot fix or input validation this repo already has.
Nothing to borrow; this implementation is ahead of them.
## Brainstorm: Evaluated Approaches
### Recommend — small, honest, non-breaking
**1. Razor-edge caveat in bot replies** (resolves known-issues open question 1)
- What: hardcode the 7 disputed month-boundary JDs (09/12/2072, 15/11/2077, 07/05/2130, 26/05/2150,
17/05/2159, 22/01/2175, 26/01/2199). When a conversion's month start or next-month start is one of
them, append one line: result near a disputed lunar-month boundary, may differ ±1 day from future
official tables.
- Affects ~413 of 146,097 days; zero risk to correct output; converts a silent known-wrongness into
stated uncertainty. Cost: a small table + one condition + tests.
- Trade-off: nobody realistically queries 2130 from a Telegram bot — pure-YAGNI reading says skip.
But the cost is ~30 LOC and it closes a documented open question permanently.
**2. Leap-month ambiguity hint in `/duonglich`** (fixes the "correct output reported as bug" item)
- What: when the resolved (possibly defaulted) month equals that lunar year's leap month and no
`nhuan` flag was given, append a hint: "Năm nay có tháng X nhuận — thêm 'nhuan' nếu ý bạn là
tháng nhuận." Detection is one `getLeapMonthOffset` call on the already-computed a11.
- Alternatives considered: (a) reply with *both* conversions — noisier, two answers where user wants
one; (b) leave as-is — keeps a documented user-confusion source. Hint is the KISS winner:
single authoritative answer + self-service disambiguation.
**3. Golden-table regression corpus** (optional hardening)
- What: generate once, from the current verified engine, a compact per-year record (leap-month index +
12/13 month lengths) for all 400 years; commit as testdata; test decodes and compares.
- Why round-trip isn't enough: `TestSolarLunarRoundTrip` proves *self-consistency*; a future change
could shift a month boundary consistently in both directions and pass. `knownDates` +
`TestLeapMonthTable` pin samples only. A golden table freezes the full verified behavior and makes
any future engine experiment a reviewable one-file diff.
- Cost: ~1 generator run + ~4 KB testdata + one test. Verdict: worth it, do alongside #1.
### Reject — with reasons pinned
**ΔT model update.** New IERS data makes the polynomial *more* wrong, yet updating it is still wrong to
do: the module's value is bit-compatibility with the reference algorithm every Vietnamese app runs.
A "better" ΔT flips razor-edge dates away from ecosystem consensus → user-visible mismatches
reported as bugs, with no authority to say we're right. Revisit only if official 2072+ tables appear
(known-issues open question 4). Approach #1 (caveat) is the correct treatment of this uncertainty.
**Pre-1968 historic mode.** Ground truth fragments by government (North/South differ 1955–67, dynastic
before 1945); a correct implementation is a research project, not a module feature; the proleptic
astronomical calendar is what every reference source shows for those years anyway. Current behavior
already matches the published record on both verifiable disputes. Keep documented, don't build.
This *strengthens* the known-issues answer to open question 2: even a user-reported mismatch should
trigger a doc note, not a UTC+8 mode.
**Table-driven rewrite.** A table must be generated from something. From this engine → just a cache of
identical output (Go float64 JD math has ~4.5e-10-day ulp vs 0.0014-day decision margins; no
platform-flip risk to cache away). From official tables → they don't exist past ~2100. Table-driven
is how you'd start from scratch; with a verified engine it adds a second representation to keep in
sync (DRY violation) for zero accuracy. The useful 20% of this idea is #3 (table as *test* data).
**Range extension beyond 1800–2199.** Bound exists because published references stop there; claims
outside are unverifiable. Nothing found changes that.
**Feature creep** (ngày can-chi, tiết khí, giờ hoàng đạo, holiday lookup): out of scope until a user
asks. Noted so it isn't re-brainstormed.
## Success Criteria (if #1–#3 are implemented)
- All existing tests pass unchanged, incl. `knownDates` pins (20/6/1944, 7/7/1967).
- Caveat appears for a 2072+ razor-edge date, absent for ordinary dates (both directions of conversion).
- `/duonglich 5/5/2028` (leap-5 year) shows hint; `/duonglich 5/5/2028 nhuan` and non-leap years don't.
- Golden table regenerated from HEAD is byte-identical to committed testdata.
## Next Steps
1. Decide which of #1/#2/#3 to implement (recommendation: all three; #2 first — most user-visible).
2. `/ck:plan` with this report as context if proceeding; scope is small enough for a single phase.
3. Update `docs/amlich-known-issues.md` open questions 1–3 with the resolutions above once implemented.
## Sources
- [Hồ Ngọc Đức — Vietnamese lunar calendar](https://www.xemamlich.uhm.vn/vncal_en.html)
- [Hồ Ngọc Đức — Historic Vietnamese lunar calendar](https://www.xemamlich.uhm.vn/histcal.html)
- [Vietnamese calendar — Wikipedia](https://en.wikipedia.org/wiki/Vietnamese_calendar)
- [IERS: no leap second in 2024 — DCD](https://www.datacenterdynamics.com/en/news/no-leap-seconds-added-to-universal-time-in-2024-iers-says/)
- [Earth rotation records spur Oct 2026 CGPM vote — TechTimes](https://www.techtimes.com/articles/320185/20260711/earth-rotation-records-spur-october-vote-avert-negative-leap-second.htm)
- [Negative leap second outlook — timeanddate](https://www.timeanddate.com/time/negative-leap-second-maybe.html)
- [Earth rotation acceleration analysis — Astronomy Reports 2024](https://arxiv.org/html/2404.06343v3)
- Go ports surveyed: [hungtrd/amlich](https://github.com/hungtrd/amlich), [buichuongvnua/amlich](https://pkg.go.dev/github.com/buichuongvnua/amlich), [go-dyn/vcalendar](https://pkg.go.dev/github.com/go-dyn/vcalendar)
## Unresolved Questions
1. Caveat wording/threshold for #1: flag only the two months touching a disputed boundary (proposed),
or the whole lunar year? Proposed: months only — year-wide is alarmist.
2. Should the leap-month hint (#2) also fire when month+`nhuan` *was* given but the defaulted year
was filled in (user may have meant a different year)? Proposed: no — over-engineering.
3. If leap seconds are abolished (Oct 2026 vote), does Vietnam's calendar follow civil UTC+7 or
UT1+7? Unanswerable today; park with known-issues open question 4.
@@ -1,76 +0,0 @@
---
title: Open-source web gacha projects as a code source for /api/gacha
date: 2026-10-01 16:56 (Asia/Saigon)
---
# Open-source web gacha projects as a code source for /api/gacha
## Answer
No candidate offers reusable code that would make the gacha wish look more like the
real game. The realistic simulators get their look by playing MP4/WebM clips
recorded from the commercial games. Their code is often MIT-licensed, but the
clips belong to the publisher (HoYoverse, Nexon, and others). Mantan21's README
says so directly: "all assets used for this application belongs to Hoyoverse".
The projects with their own art (original games) have simple pixel or HTML
reveals that are weaker than the current gacha wish. Copying either kind of code
would give us one of two things: a `<video>` player that needs copyrighted
footage, or an animation worse than what we already render.
## Candidates
Stars, licences, and dates come from the GitHub API on 2026-10-01.
| # | Project | Stars | Stack | Code licence | How the pull animation is made | Reusable for us? |
|---|---|---|---|---|---|---|
| 1 | [Mantan21/Genshin-Impact-Wish-Simulator](https://github.com/Mantan21/Genshin-Impact-Wish-Simulator) ([live](https://wishsimulator.app)) | 293 | Svelte | MIT | Plays game-recorded clips (`3star-single.mp4` … `5star-multi.mp4`, `bg.webm`) through `_meteor.svelte` | Code yes, footage no |
| 2 | [shadorki/genshin-impact-wish-simulator](https://github.com/shadorki/genshin-impact-wish-simulator) | 734 | JavaScript | None (all rights reserved) | Plays `dist/videos/5starwish.mp4` and similar game clips | No |
| 3 | [Mantan21/HSR-Warp-Simulator](https://github.com/Mantan21/HSR-Warp-Simulator) ([live](https://hsr.wishsimulator.app)) | 40 | Svelte | MIT | Same author and approach, built on Star Rail game assets; the warp clip source was not checked | Code yes, assets no |
| 4 | [jianzhishendi/Genshin-Impact-Wish-Simulator-1](https://github.com/jianzhishendi/Genshin-Impact-Wish-Simulator-1) | — | Svelte | Fork of #1 | Same as #1 | Same as #1 |
| 5 | [U1805/ba-gacha](https://github.com/U1805/ba-gacha) | 18 | Vue | MIT | `VideoView.vue` plays Blue Archive clips per star tier | Code yes, footage no |
| 6 | [dzmuh97/genshin-twitch-wish](https://github.com/dzmuh97/genshin-twitch-wish) | 2 | Python | None | Composites game `effect.mp4` backgrounds | No |
| 7 | [Eling486/gacha-simulator](https://github.com/Eling486/gacha-simulator) | 23 | JavaScript | None | Arknights Spine skeletons (`.atlas`/`.skel`) extracted from the game | No |
| 8 | [Spelljinxer/nikke-gacha-sim](https://github.com/Spelljinxer/nikke-gacha-sim) | 9 | HTML | None | Game images, no video | No |
| 9 | [Catgenova/browsergacha](https://github.com/Catgenova/browsergacha) | 0 | Vanilla JS + canvas | None | Original pixel-art summon screen with free UI packs | No licence, and simpler than ours |
| 10 | [HauntedBees/Public-Domains](https://github.com/HauntedBees/Public-Domains) | 1 | JavaScript | AGPL-3.0 | Public-domain art, static reveal; last push 2019 | AGPL would apply to our service; little animation to gain |
| 11 | [DragonMarquise/GachaGameSimulator](https://github.com/DragonMarquise/GachaGameSimulator) | 7 | HTML/CSS/JS | AGPL-3.0 | Basic CSS reveal | AGPL, and simpler than ours |
Tracker and analyser tools (for example `biuuu/genshin-wish-export`,
`lgou2w/HoYo.Gacha`, `bhaoo/endfield-gacha`) came up in the searches but have
no pull animation, so they are excluded.
## What the code would actually give us
- **Video player and skip handling (#1, #5).** We already render to MP4, so
there is nothing to gain.
- **Pull rates and pity logic (#1).** MIT and realistic. It doesn't fit our
contract, though: the bot picks the winner uniformly and the caller sets
`rarity`. Adopting it would change bot behaviour, not visuals.
- **Spine or Lottie playback (#7).** The technique could work through
`@remotion/lottie`, but only with original animation files, and none exist
in these repositories.
## Honest options from here
1. **Keep procedural rendering and add original painted textures**, as
recommended in the previous discussion: images generated once with an
image model or supplied by you, animated by the current code.
2. **Commission or author a Lottie or Spine wish animation** and play it
through `@remotion/lottie`. This gives the highest fidelity without game
assets, but someone must make the animation file.
3. **Use the game's recorded clips** as #1–#6 do. Not recommended: HoYoverse
owns the footage, the README promises no game assets, and the bot is public.
## Method
GitHub search (`gh search repos`, `--topic gacha`), repository trees and
READMEs read through the GitHub API, plus two web searches. I checked
animation sources by listing video and Spine files in each tree and reading
Mantan21's `meteor-loader.js` and `_meteor.svelte`.
## Unresolved questions
- Is there a commercial or Creative Commons Lottie wish animation you would
accept, if we went with option 2?
- How does the HSR simulator (#3) source its warp clip? I didn't check,
because it would not change the recommendation.
@@ -1,75 +0,0 @@
---
title: Open-source gacha animations built in code (no video playback)
date: 2026-10-01 17:03 (Asia/Saigon)
---
# Open-source gacha animations built in code (no video playback)
## Answer
No mature open-source web project renders a Genshin-quality gacha pull in
code under a licence we can use. The web projects that draw effects in code
are general effect or particle libraries, or card-pack reveals, not wish
sequences. Non-web projects, such as a Unity Arknights roll recreation, are
excluded. The most promising building block is **rollshade** (MIT): a three.js
effect library with meteors, impacts, bloom, and camera shake. It is two days
old, though, and its update loop is stateful, which conflicts with Remotion's
frame-by-frame rendering.
## Web candidates (code-built, no video)
Data comes from the GitHub API and the repositories' READMEs on 2026-10-01.
"Video files" counts `.mp4`/`.webm`/`.mov` files in each repository tree.
| # | Project | Stars | Stack | Licence | Video files | What it is | Fit |
|---|---|---|---|---|---|---|---|
| 1 | [tsuyatt/rollshade](https://github.com/tsuyatt/rollshade) ([gallery](https://rollshade.tsuyatt.com/gallery/)) | 0 | three.js r186, WebGPU/TSL with WebGL2 fallback | MIT (npm `rollshade@0.1.0`) | 0 | 25 seeded spell effects (meteor, beam, impact, portal…) × 10 elements, with bloom and camera shake | Best effect source. Risks below. |
| 3 | [briamkin/card-pack-animations](https://github.com/briamkin/card-pack-animations) | 0 | TypeScript, React wrappers | MIT | 0 | Card-pack tear and reveal | Different genre, small |
| 4 | [paubineau/pack-cards](https://github.com/paubineau/pack-cards) | 0 | Vanilla JS and CSS | MIT | 1 (demo) | Pack wrapper, reveal animation, card materials | Different genre |
| 5 | [Tmauc/prismo](https://github.com/Tmauc/prismo) | 0 | React, CSS-only | None | 0 | Holographic foil card effects for 10+ rarities | No licence |
| 6 | [Takuro-U/gacha-animation](https://github.com/Takuro-U/gacha-animation) | 0 | React, Tailwind | None | 0 | Small gacha UI demo | No licence |
| 7 | [drawcall/Proton](https://github.com/drawcall/Proton) | 2477 | JS particle engine (canvas, WebGL, pixi) | MIT | — | General particle library | Building block only |
| 8 | [pixijs-userland/particle-emitter](https://github.com/pixijs-userland/particle-emitter) | 852 | PixiJS | MIT | — | Particle emitter with a visual editor | Building block only |
## Using rollshade inside Remotion
```js
// rollshade's own loop: time is wall-clock deltas, state accumulates.
fx.play(effect('meteor', 'fire', {seed: 7}), {from: start, to: target});
fx.update(timer.getDelta());
```
The risks, roughly in order of severity:
- **Determinism.** Remotion renders frames out of order across several
browser tabs, but `fx.update(dt)` accumulates state. Each frame would have
to replay from frame 0 with fixed steps (`1/fps`), so the cost grows with
clip length, or renders would have to run with concurrency 1.
- **Rendering speed.** The server has no GPU. WebGPU will fall back to WebGL2
on SwiftShader, which is likely several times slower than our CSS frames.
The gacha wish at the time already took about 15s at 854px and 30fps against the
15s timeout.
- **Maturity.** Version 0.1.0, first published this week, pinned to one three
release (r186), and the API can still change.
- **New dependencies.** `three`, `rollshade`, and `@remotion/three` with
`@react-three/fiber`. That conflicts with our "CSS over custom rendering"
rule in AGENTS.md, so you'd need to approve it.
## Options
1. **Prototype one shot with rollshade.** Render the meteor and impact shot in
a separate composition, measure the render time on this server, and check
frame determinism before committing to a rewrite. About one session of
work, with no changes to `/api/gacha` until it proves out.
2. **Keep our own code and borrow techniques.** Port ideas such as rollshade's
seeded variations, bloom-like layering, and impact timing into the existing
CSS and canvas renderer. No new dependencies, and no render-time risk.
3. **Painted textures plus our current motion** (from the previous report).
Not code-only, but the biggest visual gain for the cost.
## Unresolved questions
- Would you accept adding three.js to the renderer if the prototype renders
inside the timeout?
- I couldn't browse rollshade's live gallery here (no browser on this server),
so I haven't seen its meteor effect myself.
@@ -1,58 +0,0 @@
# Scout Report: rebrand miti99bot → tiennm99bot
Baseline: `main` @ c18006f plus the uncommitted `BOT_USERNAME` change (username
is now runtime-resolved; sticker default = `miti99_by_<bot username>`).
## In-repo occurrences (159 files)
### Go module path — 315 import lines, ~150 files
- `go.mod:1` `module github.com/tiennm99/miti99bot`
- Every `internal/...` import in `cmd/` and `internal/`. Mechanical rewrite.
- `internal/modules/util/help.go:16` `repoURL`; asserted in `util/help_test.go:62,165`.
### Runtime branding strings
- `internal/server/health.go:20` — `"miti99bot ok\n"` (health body; docs quote it).
- `internal/deploynotify/deploy_notify.go:74` — `"🚀 miti99bot deployed: %s"`; test `deploy_notify_test.go:89`.
- User-Agent `Mozilla/5.0 (miti99bot)`: `coin/price_providers.go:261`, `gold/vnappmob_client.go:127,233`, `stock/prices_ssi.go:152`; asserted in `stock/prices_test.go:66-67`.
- `lol/api_client.go` `userAgentProduct = "miti99bot/0.1"`; asserted in `lol/api_client_test.go` (`TestClientUserAgent`).
- `monkeyd/export_job.go:42` — cache dir `miti99bot-monkeyd-cache` (temp dir; rename is harmless, old dir orphaned).
- `renderer/src/gacha/page/page.js:281` — gacha recap edition label `'miti99bot'`.
### Sticker pack slug `miti99`
- `sticker/sticker_pack.go:29` `defaultStickerPackSlug = "miti99"`, comment at `:79`.
- Tests: `sticker/addsticker_command_test.go:108,136`, `sticker/sticker_pack_test.go:24`.
- Prod sets no `STICKER_PACK_NAME`, so the live pack is the derived default `miti99_by_miti99bot`.
### Test fixtures (bot-agnostic, rename for consistency only)
- `/cmd@miti99bot`: `modules/dispatcher_test.go:92,98,110,140`, `alias/fallback_test.go:43,45`, `stats/views.go:82` (comment).
- Test DB names `miti99bot_*_test_%d`: `storage/mongo_doc_store_test.go:38`, `lol/startup_mongo_test.go:32`, `stats/startup_mongo_test.go:129`, `stock/startup_mongo_test.go:79`.
- `sticker/sticker_pack_test.go:22-23`, `sticker/addsticker_command_test.go`.
- `misc/handlers_test.go:257-263` uses `@miti99` as a *user* handle — not the brand; leave.
### Bot-scoped data
- `loldle/stickers.go` — sticker file_ids valid only for the @miti99bot account (comment line 5).
### Build / deploy config
- `.github/workflows/ci.yml:65,102` — local image tags `miti99bot`, `miti99bot-renderer`.
- `compose.yml:6` (`ghcr.io/tiennm99/miti99bot:latest`, commented), `:12` DB example, `:53` health text.
- `.env.example:1,12` header and `MONGO_DATABASE=miti99bot`.
- `renderer/package.json:2`, `renderer/package-lock.json:2,8` — `miti99bot-renderer`.
### Docs
- `README.md:1,289,293,295`, `AGENTS.md:5`, `CLAUDE.md:1`.
- `docs/deploy-coolify-selfhosted.md:3,33,118,184,249`, `docs/sticker-packs.md:27,55`.
- `renderer/README.md:3,180,181`, `renderer/docs/deployment.md:3`, `renderer/docs/miti99bot-integration.md` (file name + 3 lines).
- `plans/**` — historical records; leave untouched.
## External surfaces (outside the repo)
- GitHub repo `tiennm99/miti99bot` (git `origin`). Renaming keeps a redirect.
- Coolify app `miti99bot` on **miti-sg** (uuid `ofo63lqv73huw1hce24ntg9i`, project "Applications", running:healthy). Env keys: `TELEGRAM_BOT_TOKEN`, `MONGO_URL`, `MONGO_DATABASE`, `OWNER_ID`, `ADMIN_IDS`, `MODULES`, `LOL_PANDASCORE_TOKEN` (+ preview copies). No `STICKER_PACK_NAME`, no `BOT_USERNAME`.
- MongoDB Atlas database (value of `MONGO_DATABASE`; docs suggest `miti99bot`) and its least-privilege user scoped to that DB.
- Telegram bot account @miti99bot. Bot usernames cannot be renamed; a new @tiennm99bot means a new bot + token.
- Local checkout path `/workspace/tiennm99/miti99bot` (workspace rule: `<owner>/<repo>`).
- No GHCR publish workflow exists; the image ref is only a comment.
## Unresolved Questions
- Is the Telegram bot itself moving to a new @tiennm99bot account, or only the code/repo?
- Should the Mongo database be renamed (requires dump/restore; Mongo has no DB rename)?
- Should the sticker pack slug change (`tiennm99_by_…`), creating a new pack?
@@ -1,234 +0,0 @@
# 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.
@@ -1,411 +0,0 @@
# Test / harness / wiring review — sticker module
Reviewer lens: test quality, harness correctness, integration & wiring.
Method: read all sources, then **mutation-tested 22 production mutations** against the
suite. Security exploitation and deep state-machine correctness owned by other reviewers.
Verdict: test quality is **high** — 19 of 22 mutations killed, and the ownership gates,
callback binding, self-heal, panic barrier and count bookkeeping are all genuinely pinned.
Two rounds previously claimed "all new tests non-vacuous"; that is **refuted in three
places**: the `created`/reservation-release machinery, two `/addsticker` emoji tests, and
the module-registration wiring. None is a bug in shipped behaviour today; all are real
holes that would let a future regression land green.
---
## 1. Mutation-test results
Every mutation applied to production source only, run, then reverted. Restoration verified
by md5 + `diff -r` (see §7).
| # | Mutation applied | File | Guarding test(s) | Result |
|---|---|---|---|---|
| M1 | Delete the `ownsSet` gate from `resolveOwned` | resolve.go:116 | `TestResolveOwned_RefusalsAreIdentical` | **KILLED** |
| M2 | `!found \|\| pack.Pending` → `!found` | resolve.go:113 | `TestResolveOwned_PendingRefusesIdentically` | **KILLED** |
| M3 | Disable foreign-holder refusal in `reserveSlug` | pack_handlers.go:159 | `TestNewPack_CannotSeizeAnotherUsersPack`, `..._ForeignReservationRefusedBeforeAnyAPICall` | **KILLED** |
| M4 | Disable reservation-owner proof in `resolveStaleIntent` | pack_handlers.go:263 | `TestNewPack_StaleIntentCannotAdoptForeignName` | **KILLED** |
| **M5** | **`if created { releaseSlug }` → never release** | **pack_handlers.go:111** | *(none)* | **SURVIVED** |
| **M6** | **Delete the pre-reservation quota check entirely** | **pack_handlers.go:92-99** | *(none)* | **SURVIVED** |
| **M7** | **`reserveSlug` resumed path returns `created=true`** | **pack_handlers.go:165** | *(none)* | **SURVIVED** |
| M8 | `dropPackRecordIfSet` → `dropPackRecord` (blind by-owner delete) | delpack_callback.go:178 | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` | **KILLED** |
| M9 | Disable chat/message binding check | delpack_callback.go:137 | `..._RejectsWrongBinding` (2 subtests), `..._BystanderCannotTouchAnotherUsersPrompt` | **KILLED** |
| M10 | Remove consume-before-destructive-call | delpack_callback.go:159 | `TestDelPackCallback_SecondPressIsInert` | **KILLED** |
| M11 | Disable expiry check | delpack_callback.go:149 | `TestDelPackCallback_RejectsExpired` | **KILLED** |
| M12b | `dropPackRecord` never releases the slug | pack_handlers.go:478 | `TestSelfHeal_ReleasesTheName`, `TestDelPackCallback_ReleasesTheName` | **KILLED** |
| M13 | `isStickerSetMissing` → `err != nil` (transient read as "gone") | errors.go:163 | 4 tests incl. both `_TransientErrorKeepsRecord` | **KILLED** |
| M14 | Remove the negative-count floor | pack_handlers.go:407 | `TestDelSticker_CountFlooredAtZero` | **KILLED** |
| M15 | Invert emoji precedence (replied beats explicit) | sticker_handlers.go:283 | `TestAddSticker_HappyPath`, `..._EmojiPrecedence/explicit_wins` | **KILLED** |
| **M16** | **Early-return before `AddStickerToSet` (no API call at all)** | **sticker_handlers.go:292** | *(none — see F2)* | **SURVIVED** |
| M17 | Drop the inherit-from-replied-sticker fallback | sticker_handlers.go:283 | `..._EmojiPrecedence/inherits_from_replied_sticker` | **KILLED** |
| M18 | Remove the command panic barrier | dispatcher.go:75 | `TestInstall_CommandPanicIsContained` (binary would die) | **KILLED** |
| M19 | Remove the command-hook panic barrier | dispatcher.go:83 | `TestInstall_CommandHookPanicIsContained` | **KILLED** |
| M20 | Callback barrier `onPanic` → nil (stop answering the query) | dispatcher.go:104 | `TestInstall_CallbackPanicIsContainedAndAnswered` | **KILLED** |
| M21 | `claimSlug` `PutVersioned(…,0,…)` → `Put` | pack_handlers.go:216 | `TestNewPack_DifferentSlugAdoptsExistingSet` | **KILLED** |
| M22 | `reserveSlug` `PutVersioned(…,0,…)` → `Put` | pack_handlers.go:139 | `TestNewPack_CannotSeize…`, `..._ForeignReservation…` | **KILLED** |
| **W1** | **Delete `"mypack": ""` from `expectedParameters`** | **cmd/server/command_menu_test.go:76** | *(none — see F4)* | **SURVIVED** |
| **W2** | **Unregister `sticker` from `factories()` (import removed too)** | **cmd/server/main.go:95** | *(none — see F5)* | **SURVIVED** |
| W3 | Corrupt a non-empty expectation (`"newpack": "WRONG"`) — control | command_menu_test.go:75 | `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` | **KILLED** |
Survivors M5/M6/M7 and W2 were each re-run against the **full** sticker suite / **full repo
suite** (`./...`), not just a `-run` subset. All still survived.
---
## 2. Findings
### F1 — HIGH — the `created` reservation-release machinery has zero test coverage
`internal/modules/sticker/pack_handlers.go:106-115` and `:138-165`.
Both directions of the `created` flag survive mutation:
- **M5** (never release): survived the full suite.
- **M7** (always release, even a resumed reservation): survived the full suite.
Both branches are reachable. I proved it with two temporary probe tests (since removed):
- *Release-on-bail is real*: `/newpack newslug` while a pending record for `oldslug`
exists and the old set resolves → `reserveSlug` writes `newslug` (`created=true`),
`claimSlug`→`resolveStaleIntent` adopts `oldslug` and returns `done=true`, so
`releaseSlug(newslug)` must run. Under M5 the probe failed with
`newslug still reserved with no pack behind it - name burned`. Reservations are global
and permanent, so this is a per-invocation namespace leak.
**`TestNewPack_DifferentSlugAdoptsExistingSet` (pack_handlers_test.go:111) walks exactly
this path and asserts nothing about `newslug`.** One added line closes it.
- *Not-releasing-a-resumed-reservation is real*: reserve `oldslug` for the caller, pending
record under a different slug, `getStickerSet` fails unclassifiably → `resolveStaleIntent`
bails `done=true`. Under M7 that probe failed: the caller's pre-existing reservation was
destroyed, handing the name to the next asker while the set may still exist.
**`TestNewPack_ResumedReservationSurvivesABail` (pack_handlers_test.go:573) does not test
what its name says.** In that test `claimSlug` returns `done=false` (same-slug resume), so
the `if created` branch is never reached; the bail happens later in `createOrAdopt`, which
never consults `created`. M7 survives it. It is a duplicate of
`TestNewPack_UnknownLookupErrorAborts` wearing a different name.
Fix: assert `newslug` is unreserved in `TestNewPack_DifferentSlugAdoptsExistingSet`, and
re-point `TestNewPack_ResumedReservationSurvivesABail` at a bail inside
`claimSlug`/`resolveStaleIntent` (a pending record under a *different* slug + a 500 from
`getStickerSet` reaches it).
### F2 — MEDIUM — two `/addsticker` emoji tests pass when no sticker is added at all
`handlers_test.go:134-159` (`TestAddSticker_EmojiPrecedence`) and `:163-178`
(`TestAddSticker_FallsBackToDefaultEmoji`).
Both use the pattern:
```go
for _, call := range rb.Sent() {
if call.Method == "addStickerToSet" && !strings.Contains(call.Form["sticker"], tc.want) {
t.Errorf(...)
}
}
```
Zero matching calls ⇒ zero assertions ⇒ pass. **M16** confirms it: an early `return` placed
before `b.AddStickerToSet` leaves both tests green. They are half-live (M15/M17 kill
individual subtests via the emoji value) but they do not guard "a sticker was added".
`TestAddSticker_HappyPath:108` has the right guard (`countMethod(...) != 1` + `Fatalf`).
Add the same two lines to both tests. Same latent shape at `resolve_test.go` — no, those
use explicit `countMethod` comparisons and are fine.
### F3 — MEDIUM — `docs/sticker-packs.md` contradicts the reservation lifecycle it describes
`docs/sticker-packs.md:99-101`:
> "A name is claimed only when a pack is actually created, and it is released when that
> pack is deleted … A `/newpack` that is refused claims nothing."
The first clause is false and inverts the module's central safety property. `reserveSlug`
writes the reservation **before Telegram is touched** — that write-ahead claim is precisely
what makes adoption safe, and `pack.go:236-239` plus `pack_handlers.go:120-137` say so at
length. A name is therefore held while a `/newpack` is merely *pending*, and
`TestNewPack_UnknownLookupErrorAborts:181` asserts the reservation **must survive** when no
pack was created. An interrupted `/newpack` holds its name indefinitely with no pack behind
it — the doc tells a reader the opposite.
The second clause is also too strong: a *classified* refusal releases, but an unknown
`getStickerSet`/`createNewStickerSet` failure deliberately keeps both intent and
reservation (`pack_handlers.go:325-331, 347-356`).
Everything else in the doc checks out against source: 512px long edge / 100×100 thumbnail
(`image.go:23,25`), 4096px cap (`maxDecodeDimension = 4096`), 2 MB (`maxSourceBytes`),
120 stickers, 1–20 emoji, 10-minute confirm TTL, 10-second handler deadline, slug rules,
`MODULES` semantics, uniform ownership refusals.
### F4 — LOW — `command_menu_test` does not verify that a command has an expectation
`cmd/server/command_menu_test.go:104`: `got != expectedParameters[command.Name]`. A missing
map key yields `""`, so any public command with empty `Parameters` that nobody added to the
map passes silently. **W1** confirms: deleting `"mypack": ""` changes nothing. Four of the
nine new entries (`mypack`, `delsticker`, `setpackicon`, `delpack`) are therefore
decorative. The test does not verify what its name ("AllPublicCommandsHaveSafeMetadata")
promises for parameterless commands.
Fix: `want, ok := expectedParameters[command.Name]; if !ok { t.Errorf("no expectation for /%s") }`.
That also turns the test into the missing registration guard for F5.
### F5 — MEDIUM — nothing pins that the sticker module is registered at all
**W2**: with both the import and `"sticker": sticker.New` removed from `cmd/server/main.go`,
`go test ./...` is **fully green** — all 25 packages pass, including
`internal/modules/sticker` (its tests construct `state` directly and never go through
`factories()`). A bad merge or rebase that drops the factory line ships a bot with none of
the nine commands and a green CI.
`command_menu_test.go` only iterates whatever `reg.PublicCommands()` returns, so an absent
module is invisible to it. Cheapest fix is F4's `ok` check, which makes the map an
inventory rather than a lookup.
### F6 — LOW — `/newpack` documented but unregistered `MODULES` default changed silently
`.env.example` flips `MODULES=` (empty ⇒ load everything) to an explicit 12-module list.
The list matches `factories()` exactly (verified key by key), so no module is dropped today.
But it is now a hand-maintained duplicate of `factories()` with no test tying the two
together — the next module added will be silently excluded for anyone starting from the
template. Worth a comment pointing at `factories()`, or a test.
---
## 3. Harness review — `internal/testutil/recording_bot.go`
All four questions checked empirically. **The harness changes are correct and
backwards-compatible.**
- **`FailMethodCode` produces the real sentinels — confirmed.** The library switches on
`r.ErrorCode` decoded from the *body* and ignores the HTTP status
(`go-telegram/bot@v1.20.0/raw_request.go:102-131`). `FailMethodCode` marshals
`{"ok":false,"error_code":…,"description":…}`, so `errors.Is(err, bot.ErrorBadRequest)`
holds. `recording_bot_test.go:113` pins this, and `:130` pins the negative contrast for
bare `FailMethod`. The doc comment's `raw_request.go:103-125` citation is accurate.
- **`StubMethod` / `FailMethod` precedence is correct and tested.** `handle()` checks
`shouldFail` before `hasStub` (`:200-209`); `TestRecordingBot_FailureWinsOverStub` covers it.
- **`Reset()` is coherent and unchanged for existing callers.** It clears `calls` only —
which is exactly what it did before this changeset; the diff only adds a doc comment
explaining it. `nextMessageID` is deliberately not reset (IDs stay unique across a
Reset), which no caller depends on. All 13 other packages that use the harness call only
`Reset()`; **no module outside `sticker` uses `FailMethod`, `FailMethodCode` or
`StubMethod`**, so the new precedence rule cannot affect them.
- **Message IDs start at 1, not 0.** `handle()` increments *before* assigning
(`:192-195`), so the first `sendMessage` returns `message_id: 1`. This matters because
production rejects a binding with `action.MessageID == 0` (`delpack_callback.go:137`) —
I verified the full round trip (`/delpack` → press the button the handler itself
produced → `deleteStickerSet` fires exactly once). No collision. *(Observation only:
no test in the suite actually performs that round trip; the pieces are covered
separately.)*
**One real harness concern (MEDIUM):**
The multipart-parse tolerance (`:180`) is justified — I confirmed `getMe` genuinely fails
with `multipart: NextPart: EOF` and `ContentLength=-1`, so the old code made every
parameterless method untestable. But the tolerance is **wider than the justification**: I
posted a deliberately malformed multipart body (`Content-Type: multipart/form-data;
boundary=zzz` with non-multipart content) to `/sendMessage` and the harness answered
**HTTP 200** and recorded `{Method:sendMessage Form:map[]}`. A real Telegram would 400 it.
Impact is bounded — the bot library always builds well-formed multipart, so production
cannot realistically emit garbage. The live risk is **masking**: any test asserting a form
field is *absent* would falsely pass if the whole form silently failed to parse. Such
assertions already exist outside this module, e.g.
`internal/modules/stock/dividend_flow_test.go:115,139` (`calls[1].Form["reply_markup"] != ""`).
Suggested narrowing: tolerate only the empty-body case (`r.ContentLength <= 0`, or the
`NextPart: EOF` shape) and keep the 400 for genuinely malformed bodies; or record a
`ParseFailed bool` on `SentCall` so a masked parse is visible in `dumpCalls()`.
---
## 4. Untested error branches
Prioritised by blast radius if a bug landed there. `✗` = no test reaches the branch.
**`pack_handlers.go` — state-corrupting or namespace-leaking:**
- `:111` `if created { releaseSlug }` — ✗ **both directions** (F1). Name-burn / name-theft.
- `:178-196` `releaseSlug`'s own cross-user ownership guard (`held.OwnerID != ownerID`) — ✗.
Defence-in-depth against a bad call site, with zero coverage; a caller passing the wrong
owner would be caught only here.
- `:325-331` `createOrAdopt` default branch is covered, but **`:347-356` create failing with
an *unclassifiable* error** (intent + reservation must both survive) — ✗. This is the exact
mirror of `TestNewPack_UnknownLookupErrorAborts` and is the higher-risk half, since a
wrong answer here strands a slug whose set may exist.
- `:396-413` `adjustCount`'s `!found` → `storage.ErrNotFound` path, and both handler
fallbacks that consume it (`sticker_handlers.go:308-314`, `:352-359`) — ✗. These
synthesise a count for the reply; a bug shows the user a wrong number.
- `:147-158` `reserveSlug` conflict-but-unreadable (`getErr != nil || !found` → treat as
taken) — ✗. Comment calls out that guessing the other way *is* the takeover.
- `:220-229` `claimSlug` non-conflict store error / re-read failure — ✗.
- `:258-272` `resolveStaleIntent` reservation-read error, and both `Put` failures
(`:267`, `:296`) — ✗.
- `:283-286` adopt-commit failure — ✗.
- `:73-77` `resolver.resolve` (GetMe) failure, `:78-81` `makeSetName` failure through the
handler — ✗ (`makeSetName` is unit-tested, the handler branch is not).
- `:92-94` pre-check store error, `:495-499` `/mypack` store error, `:531-535` /`renamepack`
store error, `:542-547` `/renamepack`'s `isStickerSetMissing` self-heal — ✗.
- `:366-369`, `:550-554` commit failures — ✗ (both are best-effort by design).
**`delpack_callback.go`:**
- `:144-147` `action.ID != id` → clear button + "replaced by a newer /delpack" — ✗.
`TestDelPack_SecondPromptSupersedesTheFirst` is rejected earlier, at the *binding* check
(`:137`), so this branch and its `clearButton` side effect never execute in tests.
- `:97-100` malformed callback data through the handler — ✗ (`parseDeleteCallback` is
unit-tested at `delpack_callback_test.go:84` for the happy case only; no test feeds
over-length, non-hex or wrong-prefix data to `handleDelPackCallback`).
- `:105-107` `query.From.ID == 0`, `:118-120` `From.ID != action.OwnerID` — ✗ (both
unreachable given the owner-keyed lookup; defence in depth).
- `:113-116` pending-store read error, `:159-165` both consume-delete failure branches — ✗.
- `:32-36` `/delpack` store error and `:37-39` `!found` ("you don't have a pack") — ✗.
- `:70-73` `SendMessage` failure — ✗. Note this is the one path in the module that returns
a **raw** API error to the dispatcher rather than a `userError`/generic reply.
- `:80-83` pending `Put` failure — ✗. Leaves a live button with no server-side action.
**`resolve.go`:**
Well covered. Only `:108-111` `getPack` store error is ✗. `resolveSource`'s `replied == nil`
refusal (`:51-53`) is ✗ directly, though `resolveOwned`'s equivalent is tested.
**Whole-feature gap — Phase 5 photo pipeline has no integrated coverage.** Every test
message is built by `stickerReply()`, which always sets `Sticker`. Grep confirms no test
constructs a `Photo:` / `Document:` reply and feeds it to a handler. Consequently
`resolvePhotoSource` (`photo.go:342`) is never executed, and **`handleSetPackIcon`
(`setpackicon.go:174`) is reached only by `TestHandlers_RefuseAnonymousSenders`, which
returns at the sender check before doing anything** — its download → resize →
`SetStickerSetThumbnail` → self-heal body is entirely untested. The pieces (`photoFileID`,
`downloadFile`, `toStickerPNG`, `toThumbnailPNG`) are individually well tested; the wiring
between them is not. `StubMethod("uploadStickerFile", …)` now makes this testable — that is
what the harness change was for.
---
## 5. Test isolation, races, lint
- `go test -race ./internal/modules/... ./internal/testutil/... ./cmd/server/...` — **clean,
exit 0, 0 `DATA RACE`**, all 17 packages ok.
- `golangci-lint run` on the four changed packages — **0 issues**.
- No shared mutable fixtures: every test builds its own `newTestState()` over a fresh
`storage.NewMemoryProvider()` and its own `RecordingBot`. No ordering dependence found.
- `syncBuffer` (dispatcher_panic_test.go:33) correctly mutex-guards the log sink for the
detached-goroutine test, and `waitForLog` polls rather than sleeping. Good.
- Two globals are mutated without isolation in `dispatcher_panic_test.go`: `log.SetDefault`
(restored via defer) and `metrics` counters (`metrics.Flush()` at :116, never reset).
Harmless today — nothing runs in parallel and no other test in `modules_test` asserts on
error counters — but the metrics assertion at :130 would become order-dependent if one
ever did. Worth a note, not a change.
- `seedPack` correctly seeds the slug reservation alongside the pack record
(handlers_test.go:45-50), and `seedInterrupted` does the same
(pack_handlers_test.go:387-398). Both carry a comment explaining that seeding the record
alone builds a state production cannot reach. This is the right instinct and it is why
M12b/M22 kill cleanly.
## 6. Wiring & plan accuracy
**Registered correctly:** all 9 commands appear in `sticker.go:22-82` with
`VisibilityPublic`, descriptions, and `Parameters` matching
`docs/command-parameter-conventions.md` (the new `<name...>` "required remaining text" row
is a genuine addition, used by `<title...>`). All 9 are menu-described and
parameter-documented. README table and `docs/sticker-packs.md` list the same 9.
**Callback prefix is unique.** `callbackPrefix = "sticker_pack:"` vs the only other
callback in the repo, `stock`'s `"stock_div:"`. `registry.go:218-220` enforces this
**bidirectionally** (`HasPrefix` both ways), so the check is real, not nominal.
**Plan accuracy** — `plan.md` status is honestly `partial`, and all 16 unchecked boxes are
live-Telegram smoke tests plus the unresolved R11 (does Telegram reserve deleted short
names). No inflated completion. Two checked boxes are contradicted by shipped code:
- `phase-03:245` — "`/newpack` where `GetStickerSet` fails with a non-missing error aborts,
**deletes the pending record**, and never calls `CreateNewStickerSet`" is `[x]`, but the
shipped code deliberately **keeps** both intent and reservation
(`pack_handlers.go:325-331`), and `TestNewPack_UnknownLookupErrorAborts:174-183` asserts
that. The file's own superseding note at `phase-03:52-55` says this step is wrong — the
checkbox was ticked against the superseded text.
- `phase-03:225` — "`sticker.go` factory registering **4 commands** + callback prefix" is
`[x]`; the shipped factory registers 9. Stale phase-scoped wording, harmless.
**`internal/modules/wordle/lookup_test.go`** — confirmed a pure `gofmt` alignment change.
The 8 map keys and all 8 values are byte-identical; only leading whitespace inside the
composite literal moved (the longest key `" crane "` no longer forces extra padding).
No behaviour change.
## 7. Final state
Every mutated file restored and verified byte-identical against a pre-review backup:
```
md5sum -c backup/md5.txt → all 31 files OK (no mismatches)
diff -r backup/cmdserver cmd/server → cmd/server IDENTICAL
diff -r backup/testutil internal/testutil → testutil IDENTICAL
diff -r backup/sticker internal/modules/sticker → sticker IDENTICAL
diff backup/dispatcher.go internal/modules/dispatcher.go → dispatcher IDENTICAL
```
Three temporary probe test files were created and removed (`zz_probe_test.go`,
`zz_probe2_test.go` in `sticker`; `zz_probe_test.go` in `testutil`); none remain.
`git status --short`:
```
M .env.example
M README.md
M cmd/server/command_menu_test.go
M cmd/server/main.go
M docs/command-parameter-conventions.md
M go.mod
M go.sum
M internal/modules/dispatcher.go
M internal/modules/wordle/lookup_test.go
M internal/testutil/recording_bot.go
M internal/testutil/recording_bot_test.go
M plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md
M plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md
M plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md
M plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md
M plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md
M plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md
M plans/260824-1051-sticker-pack-module/plan.md
?? docs/sticker-packs.md
?? internal/modules/dispatcher_panic_test.go
?? internal/modules/sticker/
?? plans/reports/correctness-review-260825-1515-sticker-module.md
?? plans/reports/security-review-260825-1515-sticker-module.md
```
Identical to the state at review start, plus the two peer reviewers' reports and this file.
`go test ./...` — **all 25 packages ok**, zero failures.
`go test -race` on all module + testutil + cmd/server packages — **ok, 0 data races**.
`golangci-lint run` on changed packages — **0 issues**.
## 8. Recommended actions
1. **(F1, high)** Assert `newslug` is released in `TestNewPack_DifferentSlugAdoptsExistingSet`;
re-point `TestNewPack_ResumedReservationSurvivesABail` at a bail inside
`claimSlug`/`resolveStaleIntent` so it kills M7. Two tests, ~6 lines.
2. **(F5 + F4, medium)** Add the `want, ok := expectedParameters[name]` presence check in
`command_menu_test.go`. Closes both the decorative-entry hole and the missing
registration guard in one edit.
3. **(F2, medium)** Add the `countMethod(rb, "addStickerToSet") != 1` + `Fatalf` guard to
`TestAddSticker_EmojiPrecedence` and `TestAddSticker_FallsBackToDefaultEmoji`.
4. **(F3, medium)** Correct `docs/sticker-packs.md:99-101` to describe the write-ahead
reservation: the name is claimed *before* Telegram is called and is held while an
attempt is pending; only a positively-classified refusal or a confirmed delete releases it.
5. **(§3, medium)** Narrow the multipart tolerance to the empty-body case, or surface a
`ParseFailed` flag on `SentCall`.
6. **(§4, medium)** Add one integrated photo test (`StubMethod("uploadStickerFile", …)`)
and one `handleSetPackIcon` happy path — the largest untested surface in the module.
7. **(§4, low)** Cover `createOrAdopt`'s unclassifiable-create-error branch and
`delpack_callback.go:144` (`action.ID != id`).
8. **(§6, low)** Untick or correct `phase-03:245`; fix the "4 commands" wording at `:225`.
## 9. Unresolved questions
- `phase-06` leaves R11 (does Telegram permanently reserve a deleted short name?) open, and
`dropPackRecord`'s comment reasons about it both ways. Nothing here can settle it without
a live token; the code's behaviour is safe under either answer, so it is correctly
deferred to the manual smoke list.
- Is the `.env.example` switch from empty-`MODULES` to an explicit list intended to change
deployed behaviour, or only to document intent? If deployments copy the template, adding
a future module will require an `.env` edit that nothing warns about.
@@ -1,260 +0,0 @@
# 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.
@@ -1,310 +0,0 @@
# Adversarial verification — sticker module, round 6
- Commit under review: `b7803ce` ("fix(sticker): prove authority before a confirmed pack delete")
- Branch: `feature/sticker-pack-module`, Go 1.27, golangci-lint v2.13.1
- Method: static enumeration + driven end-to-end probes + mutation testing.
All source mutations were backed up, restored, and verified byte-identical
(`git status --short` empty, md5sums match baseline).
## Verdict
**The security fix is correct.** I could not reach any of the seven
owner-unscoped Telegram mutations with authority the caller does not hold,
under serial dispatch or under forced concurrency. The R5 hole
(`DeleteStickerSet` via a stale confirmation) is closed twice over, and I
confirmed by driven probe that the allowlist alone still holds in the one
production state where change 2 fails.
**The recurring pattern did repeat, one level down.** The flagship new test is
vacuous with respect to the guard it is named for, and the two disjuncts of the
new allowlist that the commit message itself identifies as the R5 bug are
completely unpinned. Nothing in the suite stops this fix from regressing back
into exactly the blocklist it replaced.
That is a test-integrity defect, not a live exploit. See "Merge position".
---
## 1. Enumeration of every owner-unscoped Telegram mutation
`grep` over `internal/modules/sticker/*.go` (non-test) yields exactly these
mutating calls. For each: what proves ownership at the moment of the call, and
whether that proof can go stale or be manufactured.
| Call | Site | Proof of authority at call time | Can it go stale / be forged? |
|---|---|---|---|
| `DeleteStickerSet` | `delpack_callback.go:210` | Under `lockUser`, immediately before the call: `found && !current.Pending && ownsSet(current, action.SetName)` re-read from the store | **No.** Non-`Pending` records are written only by `finishNewPack` (after a successful `CreateNewStickerSet`) and by `commitPack` from `adjustCount`/`handleRenamePack`, both of which copy an existing record's `Name`. So a non-`Pending` record proves this owner created that set. Gap between check and call is one store `Delete` on the pending key, no dispatch point. |
| `CreateNewStickerSet` | `pack_handlers.go:370` | `GetStickerSet(pack.Name)` must positively return `STICKERSET_INVALID` in the same handler | No adoption path remains; `err == nil` (occupied) drops the intent and releases the reservation. Verified by probe C below. |
| `SetStickerSetTitle` | `pack_handlers.go:591` | `getPack(ownerID)` found and `!Pending`; `Name` taken from that record | Owner-keyed read, same handler. Read happens *before* `lockUser` — hypothetical-concurrency only (see L2). |
| `SetStickerSetThumbnail` | `setpackicon.go:44` | `resolveOwned` → `ownsSet(pack, replied.Sticker.SetName)`; `Name` from the caller's own record | Slow media leg sits between check and call, but no dispatch point under serial dispatch. |
| `AddStickerToSet` | `sticker_handlers.go:69` | `getPack(ownerID)` found and `!Pending`; `UserID` is always the caller, `Name` from the caller's record | Same shape. |
| `DeleteStickerFromSet` | `sticker_handlers.go:122` | `resolveOwned` → `ownsSet(pack, st.SetName)` on the replied sticker | `st.SetName` and `st.FileID` come from the same Telegram-rendered `Sticker`; not client-forgeable. Old scrollback stickers from a deleted-then-reclaimed pack are stopped because the record is dropped alongside the set. |
| `SetStickerEmojiList` | `sticker_handlers.go:175` | `resolveOwned` | Same. |
| `SetStickerPositionInSet` | `sticker_handlers.go:214` | `resolveOwned` | Same. |
**No deferred capability exists anywhere except `PendingDelete`.** Every other
command resolves authority and consumes it inside the same handler invocation,
so the stale-authority shape found at `/delpack` has no sibling at
`/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`
or `/renamepack`. I drove `/addsticker` and `/delsticker` end to end against a
record that had moved on; both refuse at `resolveOwned`/`getPack`.
## 2. Attacks driven end to end (probe results)
Probes were written as a temporary test file, run, and removed.
| Probe | Setup | Result |
|---|---|---|
| **A** — record gone, confirmation alive | non-`Pending` pack + live `PendingDelete`, record deleted out from under it, victim seeded holding `mypack_by_testbot` | `methods = [editMessageReplyMarkup answerCallbackQuery]`. **0 `deleteStickerSet`.** Victim record intact. Allowlist holds. |
| **A2** — record moved to `Pending`, confirmation alive | same, record replaced with a `Pending` intent naming the same set | **0 `deleteStickerSet`.** |
| **C** — `/delpack` on a `Pending` record frees a name with a live set behind it, next claimant attacks | Bob's interrupted attempt created the set; Bob `/delpack` (frees `mypack`); Alice `/newpack mypack` | Alice: `[getMe getStickerSet sendMessage]`, reply `"That pack name is taken."` No record, no adoption. Alice's follow-up `/delpack`: `"You don't have a pack yet."`, 0 API calls. **`createPack`'s occupancy probe is the wall and it holds.** |
| **E** — `dropPackRecord` with a failing pending store | `pending.Delete` returns an error | Record deleted, reservation released, **confirmation survives**. This is the state that makes the allowlist's `!found` disjunct load-bearing in production. |
| **G** — can a `Pending` record coexist with a live confirmation via handlers alone? | `/delpack` (prompt live) then `/newpack second Two` | Refused at the precheck: `"You already have a pack (mypack)."` Not reachable through handlers — but *is* reachable after an E-style failed clear. |
| **Concurrency** — 3 goroutines (`handleDelPackCallback` + `handleDelPack` + `handleAddSticker`) × 50 iterations × 3 runs, `-race` | | No races, never more than one `deleteStickerSet`. |
Dispatch model re-confirmed serial: `internal/telegram/client.go:27-28`
(`WithSkipGetMe`, `WithNotAsyncHandlers`), no `WithWorkers` anywhere, so the
library default of one worker applies. All concurrency observations below are
labelled hypothetical.
## 3. Mutation testing
Backup → mutate → `go test ./internal/modules/sticker/` → restore.
| # | Mutation | Outcome | Killing test |
|---|---|---|---|
| 1 | Allowlist reverted to the R5 blocklist (`found && current.Pending && ownsSet(...)`) | **KILLED** | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` (`delpack_callback_test.go:286`) — *only* this one |
| 2 | `dropPendingDelete` removed from `dropPackRecord` | **KILLED** | `TestDropPackRecord_ClearsAnOutstandingConfirmation` (`pack_handlers_test.go:874`) — *only* this one |
| 3 | Resume returns `existing` verbatim (both carry-overs removed) | **KILLED** | `TestNewPack_ResumeUsesTheTitleJustTyped` |
| 3b | Only `resumed.Name = intent.Name` removed | **SURVIVED** | — |
| 4 | `releaseSlug` removed from `resolveStaleIntent`'s `isStickerSetMissing` branch | **KILLED** | `TestNewPack_DifferentSlugReplacesDeadIntent` (`pack_handlers_test.go:171`) |
| 5 | Allowlist disjunct `!found` dropped (`_ = found`) | **SURVIVED** | — |
| 6 | Allowlist disjunct `current.Pending` dropped | **SURVIVED** | — |
| 7 | Allowlist disjunct `!ownsSet(current, action.SetName)` dropped | **KILLED** | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` |
| 8 | Mutations 1 **and** 2 together | **KILLED** | all three of `StalePressLeavesTheCurrentPackAlone`, `StalePressCannotDeleteTheNextHolder`, `DropPackRecord_ClearsAnOutstandingConfirmation` |
Gates: `go build ./...` OK · `go test ./... -race -count=20` OK ·
`golangci-lint run ./...` → `0 issues.` · `gofmt -l .` → clean.
---
## Findings
### H1 — The flagship round-6 test does not exercise the round-6 guard (High, test integrity)
`TestDelPackCallback_StalePressCannotDeleteTheNextHolder`
(`internal/modules/sticker/delpack_callback_test.go:411-453`) is documented as
the regression test for the allowlist. It is not.
Reproduction (mutation 1, in isolation):
```
$ # revert the allowlist to the R5 blocklist, nothing else
$ go test ./internal/modules/sticker/ -run TestDelPackCallback_StalePressCannotDeleteTheNextHolder -v
--- PASS: TestDelPackCallback_StalePressCannotDeleteTheNextHolder (0.00s)
```
Cause: at line 434 the test calls
```go
// U's set vanishes at Telegram; a self-heal clears the record and the name.
s.dropPackRecord(ctx, testUser)
```
`dropPackRecord` now (change 2) deletes the `PendingDelete` as well, so the
callback returns at `delpack_callback.go:124`
(`pending.Get` → `storage.ErrNotFound` → `"This confirmation expired or was
already used."`) roughly fifty lines before the allowlist at line 193. The test
proves change 2, and only change 2 — which is already proven by
`TestDropPackRecord_ClearsAnOutstandingConfirmation`.
Mutation 2 in isolation also leaves this test **passing** (the allowlist then
catches it). Only the double revert (mutation 8) fails it. A test that requires
both defences to be removed before it fires cannot detect either one
regressing.
This is the fourth consecutive round in which a test was named for a behaviour
a structurally earlier guard prevents it from reaching.
**Fix:** seed the state directly instead of routing through `dropPackRecord` —
`s.store.Delete(ctx, packKey(testUser))` and leave the confirmation in place —
so the press actually arrives at the under-lock re-check. Probe A above is a
working version of that test; it passes on `HEAD` and fails under mutation 1.
### H2 — The `!found` disjunct is unpinned, and the state it guards is production-reachable (High)
Mutation 5 (`_ = found; if current.Pending || !ownsSet(current, action.SetName)`)
survives the entire suite. That disjunct is the exact half of the R5 bug the
commit message calls out first ("fell through on the two that mattered: no
record at all").
It is not dead code. `dropPackRecord` logs and continues when the pending
delete cannot be removed:
```go
func (s *state) dropPendingDelete(ctx context.Context, key string) {
commitCtx, cancel := commitContext(ctx)
defer cancel()
if err := s.pending.Delete(commitCtx, key); err != nil && !errors.Is(err, storage.ErrNotFound) {
log.Error("sticker_drop_pending_delete", "err", err)
}
}
```
Probe E confirms the resulting state on `HEAD`: pack record gone, reservation
released, confirmation still live and pressable. Probe A confirms `!found` is
what refuses the press in that state, and that without it the press lands on
whoever holds the name now. A single Mongo write failure is enough to enter it.
**Fix:** add the probe-A test (record deleted directly, confirmation left
alive, victim seeded under the same name, assert zero `deleteStickerSet`).
### H3 — The `current.Pending` disjunct is unpinned (Medium-High)
Mutation 6 survives. Reachable in production by composing H2 with a normal
`/newpack`: once a failed `dropPendingDelete` has left a confirmation alive
with no record, `/newpack` passes the precheck and writes a fresh `Pending`
intent. A `Pending` record is bookkeeping written *before* Telegram is called —
`handleDelPack` and `TestDelPack_PendingRecordDeletesNothingAtTelegram` both
say so explicitly — so it must never authorise a delete. Probe A2 shows the
guard works today; nothing pins it.
**Fix:** add probe A2 as a test.
### M1 — `resumed.Name = intent.Name` is unpinned and repoints the record at a different set under a bot rename (Medium)
`pack_handlers.go`, `claimSlug` resume branch:
```go
resumed := existing
resumed.Title = intent.Title
resumed.Name = intent.Name
return resumed, false, nil
```
Mutation 3b (removing only the `Name` line) survives the whole suite — the
title carry-over is the only half the new test covers, despite the `Name` line
being the only one of the two that changes *which set* is touched.
Probe B, driven end to end: seed an interrupted attempt with
`Name = "mypack_by_oldbot"` (bot renamed in BotFather since), stub `getMe` →
`testbot`, re-run `/newpack mypack Title`:
```
probed name = "mypack_by_testbot"
created name = "mypack_by_testbot"
stored pack = {Slug:mypack Name:mypack_by_testbot ... Pending:false}
```
Before `b7803ce` the probe targeted `mypack_by_oldbot`. If the interrupted
attempt did create that set, the old behaviour answered `slugTaken` and cleaned
up; the new behaviour creates a second set and orphans the first with no local
record pointing at it and no route to reach it through the bot. The `mypack`
reservation stays held (same slug), so no cross-user damage — this is a
resource leak and a behaviour regression, not a security defect.
It also directly contradicts the invariant `ownsSet`'s own doc comment states
(`setname.go:73-79`): "It deliberately does not re-derive the name from the
live bot username. Renaming the bot in BotFather is supported and leaves
existing set names untouched." The resume branch now re-derives it.
**Fix:** either drop the `Name` carry-over (the title fix is what the commit
message describes; the `Name` line is unexplained scope), or keep it and add a
test that pins the intent under a changed username. As written it is an
unexplained, untested line inside a security-sensitive commit.
### L1 — `ownsSet` uses Unicode case folding on a security comparison (Low, informational)
`strings.EqualFold` applies simple Unicode folding, so
`ownsSet(Pack{Name: "mypack_by_testbot"}, "mypacKk_by_testbot")` (U+212A
KELVIN SIGN) returns **true** — verified by probe F. Not exploitable: Telegram
constrains sticker-set short names to `[A-Za-z0-9_]`, `validateSlug` forces
`^[a-z][a-z0-9_]{2,39}$`, and the only two inputs are a stored record name and
a Telegram-rendered `Sticker.SetName`. Recording it because the comment
justifies `EqualFold` on casing grounds alone and does not note the folding
surface it brings along. `strings.ToLower` comparison would be equally correct
and narrower.
### L2 — Read-modify-write outside the lock in four handlers (Low, hypothetical concurrency)
`state.go`'s corrected `lockUser` comment says the lock "stays because every
mutation here is a read-modify-write, which is wrong the moment dispatch stops
being serial." Four handlers do not honour that:
- `handleDelPack` — reads the pack, then writes `s.pending`, with **no lock at
all** on the prompt path (the lock is taken only inside the `pack.Pending`
branch).
- `handleAddSticker`, `handleDelSticker`, `handleRenamePack` — `getPack` runs
*before* `defer s.lockUser(ownerID)()`, so the record they act on was read
outside the critical section.
Only `handleNewPack` takes the lock first. Moot under
`WithNotAsyncHandlers` + one worker; flagged because the comment asserts a
property the code does not have, which is precisely the class of defect change
6 was written to fix.
### L3 — `internal/keylock` package doc contradicts the dispatch model (Low, out of scope)
`internal/keylock/keylock.go:6-8`: "The bot dispatcher runs each Telegram update
in its own goroutine". `internal/telegram/client.go:18-22` and
`internal/modules/dispatcher.go:124-126` both say the opposite, and change 6
corrected `state.go` to match. Same wrong-reason-for-a-right-guard shape, one
package over. Not this commit's responsibility; worth a follow-up.
---
## Previously closed classes — re-confirmed still closed
| Class | Evidence |
|---|---|
| Post-wipe adoption | No adoption branch remains (`createPack` has only `occupied → refuse` / `missing → create` / `unknown → abort`). `TestNewPack_WipedStoreCannotAdoptSurvivingPack` passes; mutation of the occupancy branch is out of scope but the branch is asserted on directly. |
| Inconclusive probe then live set | `TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver` passes; the guard it defeated no longer exists (refusal is unconditional). |
| Pending record as delete authority | `handleDelPack` refuses to prompt for a `Pending` record and drops it locally; `TestDelPack_PendingRecordDeletesNothingAtTelegram` passes; the callback's `current.Pending` disjunct is a second wall (probe A2). |
| Name-burning DoS | Precheck ordering (record read before `reserveSlug`) intact; `TestNewPack_RefusedRunsClaimNoNames` and `TestNewPack_FreshReservationReleasedWhenClaimBails` cover it. |
| Cross-user `releaseSlug` | Ownership verified inside the operation, not the caller; `TestReleaseSlug_RefusesANameHeldBySomeoneElse` passes. |
## Change 2 audit (`dropPackRecord` now writes `s.pending`)
Every call site passes an owner the caller already owns — no cross-user aim is
possible:
| Call site | `ownerID` source |
|---|---|
| `handleDelPack` (pending branch) | `senderID(msg)` |
| `handleRenamePack` (`isStickerSetMissing`) | `senderID(msg)` |
| `handleAddSticker` / `handleDelSticker` / `handleEditSticker` / `handleOrderSticker` / `handleSetPackIcon` (`isStickerSetMissing`) | `senderID(msg)` |
| `dropPackRecordIfSet` ← delpack callback success | `action.OwnerID`, and the callback already proved `query.From.ID == action.OwnerID` and loaded the action under the presser's own key |
`senderID` additionally rejects bots, anonymous group admins
(`SenderChat != nil`) and `From.ID == 0`, so `pendingDeleteKey` can never be
built from the shared `GroupAnonymousBot` identity. Failure of the added write
is logged and non-fatal, leaving the record deleted and the confirmation alive
— the H2 state, which the allowlist covers.
## Merge position
`b7803ce` is a genuine, correct security fix and I would not block it on
correctness. What I do block on is the test claim: the commit ships a test
named for the guard it introduces, that guard can be fully reverted with the
test still green, and two of the guard's three load-bearing disjuncts have zero
coverage. Given five prior rounds where a false-clean was produced by exactly
this — a same-named test that never reaches the branch — the fix should not
land with its own regression detector inoperative.
Blocking work is small and mechanical: replace `s.dropPackRecord(ctx, testUser)`
in `StalePressCannotDeleteTheNextHolder` with a direct `s.store.Delete`, and add
the probe-A2 variant. Both are ten-line changes and both fail on `HEAD` under
the corresponding mutation.
M1 (`resumed.Name`) should be resolved before merge too — decided either way,
but not left as an untested, undescribed line in a commit about proving
authority.
## Unresolved questions
1. Is `resumed.Name = intent.Name` intentional, and if so what should happen to
a set stranded under the pre-rename name? The commit message describes only
the title fix.
2. Does Telegram reserve a deleted sticker set's short name? Plan note R11
still marks this unverified, and `dropPackRecord`'s release-the-name
behaviour is documented as a no-op if it does. Unchanged by this commit.
@@ -1,173 +0,0 @@
# 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.
@@ -1,227 +0,0 @@
# 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.
@@ -1,259 +0,0 @@
# 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?