diff --git a/plans/260818-2158-amlich-converter-improvements/phase-01-lunar-core-helpers.md b/plans/260818-2158-amlich-converter-improvements/phase-01-lunar-core-helpers.md deleted file mode 100644 index 7b4becd..0000000 --- a/plans/260818-2158-amlich-converter-improvements/phase-01-lunar-core-helpers.md +++ /dev/null @@ -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. diff --git a/plans/260818-2158-amlich-converter-improvements/phase-02-handler-replies.md b/plans/260818-2158-amlich-converter-improvements/phase-02-handler-replies.md deleted file mode 100644 index 8693f1b..0000000 --- a/plans/260818-2158-amlich-converter-improvements/phase-02-handler-replies.md +++ /dev/null @@ -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 có tháng 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. diff --git a/plans/260818-2158-amlich-converter-improvements/phase-03-golden-table-testdata-and-docs.md b/plans/260818-2158-amlich-converter-improvements/phase-03-golden-table-testdata-and-docs.md deleted file mode 100644 index 3c0874d..0000000 --- a/plans/260818-2158-amlich-converter-improvements/phase-03-golden-table-testdata-and-docs.md +++ /dev/null @@ -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. diff --git a/plans/260818-2158-amlich-converter-improvements/plan.md b/plans/260818-2158-amlich-converter-improvements/plan.md deleted file mode 100644 index d558c08..0000000 --- a/plans/260818-2158-amlich-converter-improvements/plan.md +++ /dev/null @@ -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. diff --git a/plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md b/plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md deleted file mode 100644 index 9123d2d..0000000 --- a/plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md +++ /dev/null @@ -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. diff --git a/plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md b/plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md deleted file mode 100644 index 93b4335..0000000 --- a/plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md +++ /dev/null @@ -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 `_by_` 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, "_by_" - 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 (` 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. diff --git a/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md b/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md deleted file mode 100644 index be70c00..0000000 --- a/plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md +++ /dev/null @@ -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:` → 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 ` — write-ahead intent - -`` 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 (``). 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 (``) 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 ` 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/`. - -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 `" 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. diff --git a/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md b/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md deleted file mode 100644 index 39bc89b..0000000 --- a/plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md +++ /dev/null @@ -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. diff --git a/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md b/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md deleted file mode 100644 index 85df045..0000000 --- a/plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md +++ /dev/null @@ -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). diff --git a/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md b/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md deleted file mode 100644 index 6ec2c7f..0000000 --- a/plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md +++ /dev/null @@ -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. diff --git a/plans/260824-1051-sticker-pack-module/plan.md b/plans/260824-1051-sticker-pack-module/plan.md deleted file mode 100644 index c4e9d5a..0000000 --- a/plans/260824-1051-sticker-pack-module/plan.md +++ /dev/null @@ -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 --> diff --git a/plans/260915-1108-blacklist-module/phase-01-scope-normalize-match.md b/plans/260915-1108-blacklist-module/phase-01-scope-normalize-match.md deleted file mode 100644 index fb785b8..0000000 --- a/plans/260915-1108-blacklist-module/phase-01-scope-normalize-match.md +++ /dev/null @@ -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. diff --git a/plans/260915-1108-blacklist-module/phase-02-store-and-mutations.md b/plans/260915-1108-blacklist-module/phase-02-store-and-mutations.md deleted file mode 100644 index 29b68ea..0000000 --- a/plans/260915-1108-blacklist-module/phase-02-store-and-mutations.md +++ /dev/null @@ -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. diff --git a/plans/260915-1108-blacklist-module/phase-03-rules-and-check.md b/plans/260915-1108-blacklist-module/phase-03-rules-and-check.md deleted file mode 100644 index 5f013ff..0000000 --- a/plans/260915-1108-blacklist-module/phase-03-rules-and-check.md +++ /dev/null @@ -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. diff --git a/plans/260915-1108-blacklist-module/phase-04-wiring-and-docs.md b/plans/260915-1108-blacklist-module/phase-04-wiring-and-docs.md deleted file mode 100644 index ee364a2..0000000 --- a/plans/260915-1108-blacklist-module/phase-04-wiring-and-docs.md +++ /dev/null @@ -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. diff --git a/plans/260915-1108-blacklist-module/plan.md b/plans/260915-1108-blacklist-module/plan.md deleted file mode 100644 index ac3a75e..0000000 --- a/plans/260915-1108-blacklist-module/plan.md +++ /dev/null @@ -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. diff --git a/plans/261001-0925-thoitiet-weather-module/plan.md b/plans/261001-0925-thoitiet-weather-module/plan.md deleted file mode 100644 index ba1bad2..0000000 --- a/plans/261001-0925-thoitiet-weather-module/plan.md +++ /dev/null @@ -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. diff --git a/plans/261001-1052-gacha-wish-command/plan.md b/plans/261001-1052-gacha-wish-command/plan.md deleted file mode 100644 index 1d0e090..0000000 --- a/plans/261001-1052-gacha-wish-command/plan.md +++ /dev/null @@ -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. diff --git a/plans/261002-1205-thuyvan-flood-alert/plan.md b/plans/261002-1205-thuyvan-flood-alert/plan.md deleted file mode 100644 index 875dba3..0000000 --- a/plans/261002-1205-thuyvan-flood-alert/plan.md +++ /dev/null @@ -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. diff --git a/plans/261003-1118-port-monkeyd-crawler-in-tree/plan.md b/plans/261003-1118-port-monkeyd-crawler-in-tree/plan.md deleted file mode 100644 index 6e87a1d..0000000 --- a/plans/261003-1118-port-monkeyd-crawler-in-tree/plan.md +++ /dev/null @@ -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`. diff --git a/plans/261007-1423-rebrand-tiennm99bot/phase-01-code-rebrand.md b/plans/261007-1423-rebrand-tiennm99bot/phase-01-code-rebrand.md deleted file mode 100644 index 936184d..0000000 --- a/plans/261007-1423-rebrand-tiennm99bot/phase-01-code-rebrand.md +++ /dev/null @@ -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. diff --git a/plans/261007-1423-rebrand-tiennm99bot/phase-02-cutover-and-manual-steps.md b/plans/261007-1423-rebrand-tiennm99bot/phase-02-cutover-and-manual-steps.md deleted file mode 100644 index e43793f..0000000 --- a/plans/261007-1423-rebrand-tiennm99bot/phase-02-cutover-and-manual-steps.md +++ /dev/null @@ -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`. diff --git a/plans/261007-1423-rebrand-tiennm99bot/plan.md b/plans/261007-1423-rebrand-tiennm99bot/plan.md deleted file mode 100644 index e4f21be..0000000 --- a/plans/261007-1423-rebrand-tiennm99bot/plan.md +++ /dev/null @@ -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. diff --git a/plans/journals/2026-09-15-blacklist-module-from-brainstorm-to-deploy.md b/plans/journals/2026-09-15-blacklist-module-from-brainstorm-to-deploy.md deleted file mode 100644 index 2d98983..0000000 --- a/plans/journals/2026-09-15-blacklist-module-from-brainstorm-to-deploy.md +++ /dev/null @@ -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. diff --git a/plans/journals/2026-10-02-gacha-glint-additive-blend-and-full-card-coverage.md b/plans/journals/2026-10-02-gacha-glint-additive-blend-and-full-card-coverage.md deleted file mode 100644 index dc67a15..0000000 --- a/plans/journals/2026-10-02-gacha-glint-additive-blend-and-full-card-coverage.md +++ /dev/null @@ -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. diff --git a/plans/journals/2026-10-02-thuyvan-flood-alerts-for-tan-thuan.md b/plans/journals/2026-10-02-thuyvan-flood-alerts-for-tan-thuan.md deleted file mode 100644 index 2c5f52f..0000000 --- a/plans/journals/2026-10-02-thuyvan-flood-alerts-for-tan-thuan.md +++ /dev/null @@ -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. diff --git a/plans/journals/2026-10-07-plan-rebrand-to-tiennm99bot.md b/plans/journals/2026-10-07-plan-rebrand-to-tiennm99bot.md deleted file mode 100644 index a91f620..0000000 --- a/plans/journals/2026-10-07-plan-rebrand-to-tiennm99bot.md +++ /dev/null @@ -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. diff --git a/plans/reports/brainstorm-decision-260818-2158-amlich-improvements-selection-report.md b/plans/reports/brainstorm-decision-260818-2158-amlich-improvements-selection-report.md deleted file mode 100644 index 445eb3d..0000000 --- a/plans/reports/brainstorm-decision-260818-2158-amlich-improvements-selection-report.md +++ /dev/null @@ -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. diff --git a/plans/reports/correctness-review-260825-1515-sticker-module.md b/plans/reports/correctness-review-260825-1515-sticker-module.md deleted file mode 100644 index d699976..0000000 --- a/plans/reports/correctness-review-260825-1515-sticker-module.md +++ /dev/null @@ -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. diff --git a/plans/reports/research-260902-1547-xlt1-don-xin-loi-t1.md b/plans/reports/research-260902-1547-xlt1-don-xin-loi-t1.md deleted file mode 100644 index e403e0a..0000000 --- a/plans/reports/research-260902-1547-xlt1-don-xin-loi-t1.md +++ /dev/null @@ -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. diff --git a/plans/reports/research-260930-1127-giaxang-fuel-price-source.md b/plans/reports/research-260930-1127-giaxang-fuel-price-source.md deleted file mode 100644 index 8e51403..0000000 --- a/plans/reports/research-260930-1127-giaxang-fuel-price-source.md +++ /dev/null @@ -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. diff --git a/plans/reports/research-261001-0900-thoitiet-weather-module.md b/plans/reports/research-261001-0900-thoitiet-weather-module.md deleted file mode 100644 index b213b11..0000000 --- a/plans/reports/research-261001-0900-thoitiet-weather-module.md +++ /dev/null @@ -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). diff --git a/plans/reports/research-261001-1315-gacha-tier-escalation.md b/plans/reports/research-261001-1315-gacha-tier-escalation.md deleted file mode 100644 index 90aab5d..0000000 --- a/plans/reports/research-261001-1315-gacha-tier-escalation.md +++ /dev/null @@ -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. diff --git a/plans/reports/research-261001-1332-gacha-meteor-gravity.md b/plans/reports/research-261001-1332-gacha-meteor-gravity.md deleted file mode 100644 index a32c102..0000000 --- a/plans/reports/research-261001-1332-gacha-meteor-gravity.md +++ /dev/null @@ -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. diff --git a/plans/reports/research-261001-1404-genshin-meteor-curve.md b/plans/reports/research-261001-1404-genshin-meteor-curve.md deleted file mode 100644 index 8c21dbd..0000000 --- a/plans/reports/research-261001-1404-genshin-meteor-curve.md +++ /dev/null @@ -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? diff --git a/plans/reports/research-261002-1205-thuyvan-flood-alert.md b/plans/reports/research-261002-1205-thuyvan-flood-alert.md deleted file mode 100644 index 9ef8d23..0000000 --- a/plans/reports/research-261002-1205-thuyvan-flood-alert.md +++ /dev/null @@ -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? diff --git a/plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md b/plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md deleted file mode 100644 index 512bd2f..0000000 --- a/plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md +++ /dev/null @@ -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. diff --git a/plans/reports/researcher-261001-1656-open-source-gacha-candidates.md b/plans/reports/researcher-261001-1656-open-source-gacha-candidates.md deleted file mode 100644 index 7f6a04f..0000000 --- a/plans/reports/researcher-261001-1656-open-source-gacha-candidates.md +++ /dev/null @@ -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. diff --git a/plans/reports/researcher-261001-1703-code-built-gacha-animation.md b/plans/reports/researcher-261001-1703-code-built-gacha-animation.md deleted file mode 100644 index 11bd42d..0000000 --- a/plans/reports/researcher-261001-1703-code-built-gacha-animation.md +++ /dev/null @@ -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. diff --git a/plans/reports/scout-261007-1423-rebrand-tiennm99bot.md b/plans/reports/scout-261007-1423-rebrand-tiennm99bot.md deleted file mode 100644 index 75f12a5..0000000 --- a/plans/reports/scout-261007-1423-rebrand-tiennm99bot.md +++ /dev/null @@ -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? diff --git a/plans/reports/security-review-260825-1515-sticker-module.md b/plans/reports/security-review-260825-1515-sticker-module.md deleted file mode 100644 index 02c4ea3..0000000 --- a/plans/reports/security-review-260825-1515-sticker-module.md +++ /dev/null @@ -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. diff --git a/plans/reports/test-review-260825-1515-sticker-module.md b/plans/reports/test-review-260825-1515-sticker-module.md deleted file mode 100644 index e091d77..0000000 --- a/plans/reports/test-review-260825-1515-sticker-module.md +++ /dev/null @@ -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. diff --git a/plans/reports/verify-260825-1630-sticker-round5.md b/plans/reports/verify-260825-1630-sticker-round5.md deleted file mode 100644 index e1b7131..0000000 --- a/plans/reports/verify-260825-1630-sticker-round5.md +++ /dev/null @@ -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. diff --git a/plans/reports/verify-260825-1700-sticker-round6.md b/plans/reports/verify-260825-1700-sticker-round6.md deleted file mode 100644 index 9f262f7..0000000 --- a/plans/reports/verify-260825-1700-sticker-round6.md +++ /dev/null @@ -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. diff --git a/plans/reports/verify-260825-1720-sticker-round7.md b/plans/reports/verify-260825-1720-sticker-round7.md deleted file mode 100644 index 61c9460..0000000 --- a/plans/reports/verify-260825-1720-sticker-round7.md +++ /dev/null @@ -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. diff --git a/plans/reports/verify-security-260825-1558-sticker-round4.md b/plans/reports/verify-security-260825-1558-sticker-round4.md deleted file mode 100644 index 4d6514b..0000000 --- a/plans/reports/verify-security-260825-1558-sticker-round4.md +++ /dev/null @@ -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. diff --git a/plans/reports/verify-tests-260825-1558-sticker-round4.md b/plans/reports/verify-tests-260825-1558-sticker-round4.md deleted file mode 100644 index 914a65b..0000000 --- a/plans/reports/verify-tests-260825-1558-sticker-round4.md +++ /dev/null @@ -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?