mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
chore: remove the plans directory
Finished plans, journals and reports stay in git history.
This commit is contained in:
1 parent
be46d4c19c
commit
cc9080c72e
47 files changed
-7044
No files matched your search
@@ -1,75 +0,0 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Lunar core helpers
|
||||
status: completed
|
||||
effort: S
|
||||
priority: P2
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Lunar core helpers
|
||||
|
||||
## Overview
|
||||
|
||||
Add the two pure helpers the handlers need: the disputed-boundary set + membership check, and
|
||||
leap-month existence probing. No behavior change to existing conversion functions.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: `nearDisputedBoundary(jdn int) bool` reports whether the lunar month containing
|
||||
solar day `jdn` starts or ends on one of the 7 disputed month boundaries.
|
||||
- Leap-variant existence needs NO new helper: handlers probe `lunarToSolar(day, month, year, true)`
|
||||
and treat `err == nil` as "leap variant exists" (DRY — reuses existing validation; also naturally
|
||||
suppresses the hint when e.g. day 30 doesn't exist in the 29-day leap month).
|
||||
- Non-functional: helpers stay in `lunar.go`, unexported, zero allocations on the hot path
|
||||
(package-level set built once).
|
||||
|
||||
## Architecture
|
||||
|
||||
- `disputedMonthStarts` — package-level `map[int]bool` built in a `var` initializer from
|
||||
`jdFromDate` on the 7 dates recorded in `docs/amlich-known-issues.md`:
|
||||
09/12/2072, 15/11/2077, 07/05/2130, 26/05/2150, 17/05/2159, 22/01/2175, 26/01/2199.
|
||||
- `nearDisputedBoundary(jdn int) bool` — mirror `solarToLunar`'s month-start search:
|
||||
|
||||
```go
|
||||
k := floorInt((float64(jdn) - jdNewMoonEpoch) / newMoonCycle)
|
||||
monthStart := getNewMoonDay(k + 1)
|
||||
for monthStart > jdn {
|
||||
k--
|
||||
monthStart = getNewMoonDay(k + 1)
|
||||
}
|
||||
return disputedMonthStarts[monthStart] || disputedMonthStarts[getNewMoonDay(k+2)]
|
||||
```
|
||||
|
||||
Rationale for checking next start too: if the *end* boundary of the containing month is disputed,
|
||||
the month's length (and day numbers near its end) is what may shift.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `internal/modules/amlich/lunar.go`
|
||||
- Modify: `internal/modules/amlich/lunar_test.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `disputedMonthStarts` set with a comment explaining provenance (new moon within ±2 min of
|
||||
UTC+7 midnight; see docs/amlich-known-issues.md) — no plan/audit labels in comments.
|
||||
2. Add `nearDisputedBoundary`.
|
||||
3. Test: each of the 7 JDs is an actual month start per the engine — for each date, assert
|
||||
`solarToLunar(d,m,y)` returns lunar day 1. If any assertion fails ±1 day, the doc date and the
|
||||
engine's boundary disagree: adjust the set entry to the engine's month start and flag the doc
|
||||
discrepancy in the phase report.
|
||||
4. Test `nearDisputedBoundary`: true for a mid-month day of the month starting 09/12/2072, true for
|
||||
a day in the month *before* it (whose end boundary is disputed), false for an ordinary 2072 date
|
||||
far from the boundary and for a 2024 date.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] 7 set entries verified as engine month starts by test
|
||||
- [ ] `nearDisputedBoundary` true/false cases covered as above
|
||||
- [ ] Existing lunar tests untouched and green
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Doc dates might be the full-Meeus engine's boundaries rather than the current engine's (off by
|
||||
one day). Mitigation: step 3's pin test resolves it mechanically; the ±1-day neighborhood is the
|
||||
same disputed lunation either way.
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Handler replies
|
||||
status: completed
|
||||
effort: S
|
||||
priority: P2
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Handler replies
|
||||
|
||||
## Overview
|
||||
|
||||
Wire the leap-month hint into `/duonglich` and the razor-edge caveat into both commands. Reply
|
||||
sentences gain optional trailing lines only; existing first-line format is unchanged.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional (`/duonglich` hint): after a successful conversion with `leap == false`, if
|
||||
`lunarToSolar(day, month, year, true)` returns nil error, append:
|
||||
`Lưu ý: năm âm lịch <year> có tháng <month> nhuận — thêm "nhuan" nếu ý bạn là tháng nhuận.`
|
||||
- Functional (caveat, both commands): if `nearDisputedBoundary(jd)` — where `jd` is the *solar* JD
|
||||
of the queried/resulting date — append:
|
||||
`Lưu ý: ngày này gần ranh giới tháng âm lịch chưa chắc chắn; kết quả có thể lệch 1 ngày so với lịch chính thức sau này.`
|
||||
- Hint NOT shown when `nhuan` was explicit (user already disambiguated) or when the exact leap date
|
||||
doesn't exist. Both lines may co-occur (hint first, then caveat), each on its own line after the
|
||||
main sentence, separated by `\n`.
|
||||
- Non-functional: no change to usage strings, error paths, or year-range checks.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `/amlich`: `jd := jdFromDate(day, month, year)` (already-parsed solar input) → caveat check.
|
||||
- `/duonglich`: caveat check on `jdFromDate(solarDay, solarMonth, solarYear)` from the conversion
|
||||
result; hint check via the leap-variant probe before assembling the reply.
|
||||
- Message constants live next to the usage constants in `handlers.go`.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `internal/modules/amlich/handlers.go`
|
||||
- Modify: `internal/modules/amlich/handlers_test.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add the two message constants (hint as format string taking year + month).
|
||||
2. `/duonglich`: after successful `lunarToSolar`, build reply, conditionally append hint (probe
|
||||
with `leap=true` only when input `leap == false`), then conditionally append caveat.
|
||||
3. `/amlich`: conditionally append caveat after the main sentence.
|
||||
4. Tests (extend existing fake-reply pattern in `handlers_test.go`):
|
||||
- `/duonglich 5/5/2028` → hint present (2028 has leap 5); `/duonglich 5/5/2028 nhuan` → absent;
|
||||
`/duonglich 5/5/2027` → absent (no leap 5 in 2027).
|
||||
- `/amlich` on a date inside the disputed month at 09/12/2072 → caveat present; ordinary date →
|
||||
absent. `/duonglich` case whose result lands in that month → caveat present.
|
||||
- Assert exact full reply strings (repo test style asserts exact text — keep it strict).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Hint behavior matches the three `/duonglich` cases above
|
||||
- [ ] Caveat fires for disputed-month dates in both commands, silent elsewhere
|
||||
- [ ] All pre-existing handler tests pass without assertion loosening
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Double-probe cost: one extra `lunarToSolar` call per leap-year query — microseconds, irrelevant.
|
||||
- Wording is user-visible contract; if the user wants different Vietnamese phrasing, only the
|
||||
constants change. Flag wording in the PR description for review.
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
---
|
||||
phase: 3
|
||||
title: Golden-table testdata and docs
|
||||
status: completed
|
||||
effort: S
|
||||
priority: P3
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 3: Golden-table testdata and docs
|
||||
|
||||
## Overview
|
||||
|
||||
Freeze the verified 1800–2199 month structure as committed testdata, and close the resolved open
|
||||
questions in `docs/amlich-known-issues.md`. Independent of phases 1–2.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: a test recomputes every lunar year's structure from the engine and compares
|
||||
byte-exact against `testdata/lunar-years-1800-2199.txt`; `go test -run TestGoldenTable -update`
|
||||
regenerates the file (standard `-update` flag idiom).
|
||||
- File format, one line per lunar year:
|
||||
`YYYY L n1 n2 ... nN` — `L` = leap month number (0 = none), `n*` = month lengths (29/30) in
|
||||
chronological order, tháng 1 first, leap month inserted in sequence after month `L`
|
||||
(12 entries normal year, 13 leap year).
|
||||
- Non-functional: file lives in `internal/modules/amlich/testdata/`; generation logic lives in the
|
||||
test file only (no production code).
|
||||
|
||||
## Architecture
|
||||
|
||||
- Build each year from `lunarToSolar` month starts: for lunar year Y, JDs of tháng 1..12 (+ leap
|
||||
where `lunarToSolar(1, m, Y, true)` succeeds), sorted chronologically; lengths = successive
|
||||
start-JD differences, last month's length from tháng 1 of Y+1. Simpler than sweeping days and
|
||||
exercises the lunar→solar direction the round-trip test already covers from the other side.
|
||||
- Rationale over round-trip: `TestSolarLunarRoundTrip` proves self-consistency only; a future
|
||||
engine change that shifts a boundary consistently in both conversions passes it. The golden file
|
||||
pins the actual verified placement.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/amlich/testdata/lunar-years-1800-2199.txt`
|
||||
- Modify: `internal/modules/amlich/lunar_test.go` (or new `golden_test.go` if it crowds the file)
|
||||
- Modify: `docs/amlich-known-issues.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Write `TestGoldenTable` with `var update = flag.Bool("update", false, ...)`; generator builds the
|
||||
full table string; when `-update`, write file and skip compare; else compare byte-exact with a
|
||||
diff-friendly failure message (first differing line).
|
||||
2. Generate the file once; eyeball spot checks: 2025 leap 6, 2028 leap 5, 2033 leap 11, 1944
|
||||
leap 4, 1967 no leap month (leap numbers already pinned by `TestLeapMonthTable` — must agree;
|
||||
the doc's "30/5 Đinh Mùi" for 1967 is day 30 of the regular month 5, not a leap month).
|
||||
3. Commit the generated file. Edge-case years must round-trip with the existing suite untouched.
|
||||
4. Update `docs/amlich-known-issues.md`:
|
||||
- Open question 1 → resolved: caveat line added, months touching the boundary only.
|
||||
- Open question 2 → closed (don't build): add the South-Vietnam wrinkle (UTC+7 until 1959,
|
||||
UTC+8 1960–67 per Hồ Ngọc Đức's historic-calendar page) — a single UTC+8 mode would be wrong
|
||||
for the South 1955–59, strengthening the existing conclusion.
|
||||
- "`/duonglich` defaults inside leap months" item → note the hint now self-disambiguates.
|
||||
- Keep questions 3 (ΔT) and 4 (future official tables) open; add one line noting the 2026 CGPM
|
||||
leap-second-abolition vote as a further far-future timescale uncertainty in the same bucket.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Golden file committed; regeneration from HEAD is byte-identical
|
||||
- [ ] Golden leap months agree with `TestLeapMonthTable` pins
|
||||
- [ ] Docs updated; no stale claims left in the two resolved items
|
||||
- [ ] `docs/amlich-known-issues.md` stays under docs.maxLoc (800)
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Ordering bug when inserting the leap month (nhuận m sorts after regular m) — chronological
|
||||
sort by start JD avoids hand-rolled index math.
|
||||
- `flag.Bool` at package scope collides if a flag named `update` ever exists elsewhere in the
|
||||
package's tests — it doesn't today; keep the name.
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
title: >-
|
||||
Amlich converter improvements: leap-month hint, razor-edge caveat,
|
||||
golden-table tests
|
||||
description: >-
|
||||
Three merged non-breaking improvements to internal/modules/amlich from
|
||||
brainstorm decision
|
||||
status: completed
|
||||
priority: P2
|
||||
branch: main
|
||||
tags:
|
||||
- amlich
|
||||
- telegram-bot
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: '2026-08-18T15:04:11.318Z'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Amlich converter improvements: leap-month hint, razor-edge caveat, golden-table tests
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the three improvements selected in
|
||||
`plans/reports/brainstorm-decision-260818-2158-amlich-improvements-selection-report.md`
|
||||
(research: `plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md`):
|
||||
|
||||
1. `/duonglich` leap-month ambiguity hint — when the entered month is also that year's leap month
|
||||
and no `nhuan` flag given, append a one-line hint.
|
||||
2. Razor-edge caveat — both commands warn when the result's lunar month touches one of the 7
|
||||
disputed month boundaries from 2072 on (documented in `docs/amlich-known-issues.md`).
|
||||
3. Golden-table regression testdata — freeze the verified 1800–2199 month-structure output.
|
||||
|
||||
Explicitly out of scope (rejected with verification in the research report): ΔT model update,
|
||||
pre-1968 historic mode, table-driven engine rewrite, range extension, extra calendar features.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Lunar core helpers](./phase-01-lunar-core-helpers.md) | Completed |
|
||||
| 2 | [Handler replies](./phase-02-handler-replies.md) | Completed |
|
||||
| 3 | [Golden-table testdata and docs](./phase-03-golden-table-testdata-and-docs.md) | Completed |
|
||||
|
||||
## Dependencies
|
||||
|
||||
None cross-plan. Phase 2 depends on phase 1; phase 3 is independent.
|
||||
|
||||
## Acceptance Criteria (whole plan)
|
||||
|
||||
- All existing tests pass unchanged — especially `knownDates` pins (20/6/1944, 7/7/1967),
|
||||
`TestSolarLunarRoundTrip`, `TestLeapMonthTable`.
|
||||
- `/duonglich 5/5/2028` (leap-5 year) shows the hint; `/duonglich 5/5/2028 nhuan` and non-leap
|
||||
years do not.
|
||||
- Conversions inside a lunar month adjacent to the 09/12/2072 boundary show the caveat (both
|
||||
commands); ordinary dates do not.
|
||||
- Golden file regenerated from HEAD is byte-identical to the committed one.
|
||||
- No public-contract changes; reply format only gains optional trailing lines.
|
||||
- `go vet ./...` and `staticcheck` clean (repo standard).
|
||||
|
||||
## Validation
|
||||
|
||||
`go test ./internal/modules/amlich/` after each phase; full `go test ./...` + lint at the end.
|
||||
@@ -1,160 +0,0 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Phase 1: Shared prerequisites"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Shared prerequisites
|
||||
|
||||
## Overview
|
||||
|
||||
Two gaps in shared code that the sticker module would otherwise expose. Both live outside
|
||||
`internal/modules/sticker` and both block every later phase, so they land first and merge
|
||||
independently of the feature.
|
||||
|
||||
1. **No panic barrier on the update path** (plan C9) — a handler panic terminates the
|
||||
process, and Phase 5 proposes decoding attacker-supplied images in a handler.
|
||||
2. **`RecordingBot` cannot return structured API results** (plan C11) — three later phases
|
||||
have success criteria that are unimplementable against the current harness.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: a panic in any command or callback handler is recovered, logged with the same
|
||||
context as a handler error, and does not terminate the process.
|
||||
- Functional: tests can make any Bot API method return a chosen JSON result, and can make a
|
||||
method fail with a Telegram-shaped error carrying an `error_code`.
|
||||
- Non-functional: no behaviour change for existing modules; every existing test passes
|
||||
unmodified.
|
||||
- Non-functional: the recovery path must not swallow the failure silently — it increments an
|
||||
error metric and logs at ERROR.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Panic barrier in `modules.Install`
|
||||
|
||||
`internal/modules/dispatcher.go:65-111` registers two closures per module — one for commands,
|
||||
one for callback data. Neither recovers. With `bot.WithNotAsyncHandlers()` (plan C1) the
|
||||
handler runs inline on the single polling goroutine, so an unrecovered panic ends the
|
||||
process for every user.
|
||||
|
||||
The repo already has the exact pattern to copy at `internal/cron/scheduler.go:66-74`, which
|
||||
recovers around cron handlers. Mirror it:
|
||||
|
||||
```go
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
metrics.IncError("handler-panic")
|
||||
log.Error("command panic", "command", cmdCopy.Name, "recovered", rec,
|
||||
"stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
Apply to both closures. The callback variant should also attempt `AnswerCallbackQuery` so
|
||||
the user's client stops showing a spinner, guarded so a failure there cannot panic again.
|
||||
|
||||
**Also fix the stale comment at `internal/modules/dispatcher.go:167`,** which claims
|
||||
"would panic the goroutine before our `recover()` in webhook.go".
|
||||
`internal/telegram/webhook.go` contains only `DeleteWebhook` — there has been no
|
||||
webhook-served handler since the move to long polling. The comment currently tells a reader
|
||||
a protection exists that does not.
|
||||
|
||||
### `RecordingBot` structured responses
|
||||
|
||||
`internal/testutil/recording_bot.go:178-195` (`okResponseFor`) returns
|
||||
`{"ok":true,"result":true}` for every method not in `isMessageProducingMethod`
|
||||
(`:156-165`). Methods decoding into a struct therefore always error:
|
||||
|
||||
| Method | Decodes into | Current test behaviour |
|
||||
|---|---|---|
|
||||
| `getStickerSet` | `*models.StickerSet` | `json: cannot unmarshal bool` |
|
||||
| `getFile` | `*models.File` | same |
|
||||
| `uploadStickerFile` | `*models.File` | same |
|
||||
| `getMe` | `*models.User` | same |
|
||||
|
||||
Add two capabilities, both additive:
|
||||
|
||||
```go
|
||||
// StubMethod makes method return the given JSON as its "result" field.
|
||||
func (rb *RecordingBot) StubMethod(method string, resultJSON string)
|
||||
|
||||
// FailMethodCode makes method fail with a Telegram-shaped error carrying an
|
||||
// error_code, so library errors take the same ErrorBadRequest / ErrorForbidden
|
||||
// shape production emits.
|
||||
func (rb *RecordingBot) FailMethodCode(method string, errorCode int, description string)
|
||||
```
|
||||
|
||||
`FailMethod` (`:99-112`) stays as-is for existing callers, but its doc comment must state
|
||||
that it produces a **codeless** failure that does **not** take the `ErrorBadRequest` shape —
|
||||
that distinction is what plan rule 4 depends on, and a future reader must not confuse the two.
|
||||
|
||||
Precedence when both a stub and a failure are registered for one method: the failure wins,
|
||||
so a test can override a stubbed happy path without unregistering it.
|
||||
|
||||
### Why this is a separate phase
|
||||
|
||||
Both changes touch files every other module's tests depend on
|
||||
(`internal/modules/dispatcher.go`, `internal/testutil/recording_bot.go`). Landing them
|
||||
alone, with the full suite green, keeps the blast radius reviewable and means a problem here
|
||||
is not entangled with sticker logic.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `internal/modules/dispatcher.go` (recover in both closures; fix the stale comment)
|
||||
- Modify: `internal/testutil/recording_bot.go` (`StubMethod`, `FailMethodCode`, doc fix)
|
||||
- Create: `internal/modules/dispatcher_panic_test.go`
|
||||
- Modify: `internal/testutil/recording_bot_test.go`
|
||||
- Reference: `internal/cron/scheduler.go:66-74` (the pattern to mirror)
|
||||
- Reference: `internal/metrics` (`IncError`), `internal/log` (`Error`)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add the recover to the command closure in `Install`, with metric + structured log.
|
||||
2. Add the recover to the callback closure, including a guarded `AnswerCallbackQuery`.
|
||||
3. Correct the stale comment at `dispatcher.go:167`.
|
||||
4. Add `StubMethod` and `FailMethodCode` to `RecordingBot`; document `FailMethod`'s codeless
|
||||
shape.
|
||||
5. Tests per the Todo list.
|
||||
6. Run the full suite — every existing test must pass untouched.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `recover()` in the command closure with `metrics.IncError("handler-panic")`
|
||||
- [x] `recover()` in the callback closure with guarded `AnswerCallbackQuery`
|
||||
- [x] Fix the stale `recover()` comment at `dispatcher.go:167`
|
||||
- [x] `RecordingBot.StubMethod(method, resultJSON)`
|
||||
- [x] `RecordingBot.FailMethodCode(method, errorCode, description)`
|
||||
- [x] Document that `FailMethod` produces a codeless failure
|
||||
- [x] `dispatcher_panic_test.go`: panicking command handler
|
||||
- [x] `dispatcher_panic_test.go`: panicking callback handler
|
||||
- [x] `recording_bot_test.go`: stubbed `getStickerSet` decodes into `models.StickerSet`
|
||||
- [x] `recording_bot_test.go`: `FailMethodCode` yields `bot.ErrorBadRequest`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] A command handler that panics is recovered; the test process survives and the error metric increments
|
||||
- [x] A callback handler that panics is recovered and the callback query is still answered
|
||||
- [x] `rg "recover\(\)" internal/modules/dispatcher.go` returns two hits
|
||||
- [x] No comment in the repo claims a `recover()` exists in `webhook.go`
|
||||
- [x] `rb.StubMethod("getStickerSet", ...)` lets `b.GetStickerSet` return a populated `*models.StickerSet` with a nil error
|
||||
- [x] `rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")` produces an error satisfying `errors.Is(err, bot.ErrorBadRequest)`
|
||||
- [x] `go test ./...` passes with no changes to any existing test file other than additions
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Recovering a panic can mask a real bug.** A handler that panics on every invocation would
|
||||
now fail quietly per-request instead of crashing loudly. Mitigation: log at ERROR with the
|
||||
full stack and increment a distinct `handler-panic` metric, so the condition is visible
|
||||
rather than silent. This is the same trade the cron scheduler already made
|
||||
(`cron/scheduler.go:66-74`); consistency with it is worth more than a second opinion here.
|
||||
|
||||
**Changing shared test infrastructure can break other modules' tests.** Both additions are
|
||||
new methods; no existing signature or default behaviour changes. The success criterion
|
||||
"no changes to any existing test file other than additions" is what proves it.
|
||||
|
||||
**Scope note.** The panic barrier is a pre-existing repo-wide gap, not one this module
|
||||
introduces — the module only makes it far easier to reach. It is included here on an
|
||||
explicit user decision rather than as silent scope expansion.
|
||||
@@ -1,188 +0,0 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Phase 2: Store, set names, emoji parsing"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 2: Store, set names, emoji parsing
|
||||
|
||||
## Overview
|
||||
|
||||
Pure, Telegram-free foundation: the persisted pack record, the mapping between a
|
||||
user-chosen slug and a Telegram set name, sender validation, and emoji-argument parsing.
|
||||
Every function here is unit-testable without a bot.
|
||||
|
||||
One pack per user means the store layer is a single keyed record, not a collection scan.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: persist at most one pack record per user, keyed so the caller's own user ID
|
||||
is the whole key — making the lookup itself the ownership check.
|
||||
- Functional: construct a Telegram set name `<slug>_by_<bot_username>` at creation, and match
|
||||
a sticker's `set_name` against the stored pack **without** re-deriving it from the live
|
||||
username.
|
||||
- Functional: reject senders that are bots or anonymous chat surrogates.
|
||||
- Functional: split an emoji argument run into individual emoji, accepting `😂 🔥` and `😂🔥`.
|
||||
- Non-functional: no network calls and no Telegram API types in the store layer.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pack record
|
||||
|
||||
```go
|
||||
// Pack is the single bot-created sticker set owned by a Telegram user.
|
||||
type Pack struct {
|
||||
Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL
|
||||
Name string `bson:"name"` // Telegram set name, "<slug>_by_<botname>"
|
||||
Title string `bson:"title"` // display title, mutable
|
||||
OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to
|
||||
Count int `bson:"count"` // stickers in the set; keeps /mypack API-free
|
||||
Pending bool `bson:"pending"` // write-ahead intent; see Phase 3
|
||||
CreatedAt int64 `bson:"createdAt"` // unix millis
|
||||
}
|
||||
```
|
||||
|
||||
- **Key: `strconv.FormatInt(ownerID, 10)`** — the user ID alone. One pack per user makes the
|
||||
slug unnecessary as a key component, which removes the prefix scan entirely.
|
||||
- `getPack(ctx, ownerID) (Pack, bool, error)` — one `Get`; `storage.ErrNotFound` maps to
|
||||
`false, nil`. There is no `listPacks` and no `List` call anywhere in the module.
|
||||
|
||||
This retires the worst red-team finding. The previous `listPacks` was a structural N+1
|
||||
(`mongo_doc_store.go:157-165` projects `_id` only, forcing a `Get` per key), and
|
||||
`/packlist` layered ten `GetStickerSet` calls on top of it under a 60s-per-call ceiling.
|
||||
|
||||
- `Count` keeps `/mypack` free of API calls. It is **advisory** — a user editing the pack
|
||||
through @Stickers desyncs it. Phase 3 refreshes it opportunistically.
|
||||
- `Pending` implements write-ahead intent (plan C5). Phase 3 owns the state machine.
|
||||
- `Pack`'s bson tags must not collide with `_id` / `version` / `updatedAt`; `storage.Typed`
|
||||
panics on collision (`internal/storage/doc_store.go:72`).
|
||||
|
||||
### Sender validation
|
||||
|
||||
```go
|
||||
// senderID returns the personal Telegram user behind msg, or an error when the
|
||||
// message has no usable personal identity.
|
||||
func senderID(msg *models.Message) (int64, error)
|
||||
```
|
||||
|
||||
Rejects, in order: nil `msg`/`From`, zero ID, `From.IsBot`, and non-nil `msg.SenderChat`.
|
||||
|
||||
The last two are not theoretical. Telegram substitutes a single global `GroupAnonymousBot`
|
||||
user for **every** anonymous group-admin message and puts the real origin in `SenderChat`
|
||||
(`models/message.go:86-87`). Without this check, all anonymous admins across all groups would
|
||||
share one pack — and under one-pack-per-user that is worse than it was before, because the
|
||||
first anonymous admin to run `/newpack` would block every other one and own the result.
|
||||
`rg "IsBot" internal/` returns zero hits today; `coin`, `gold`, and `stock` check only
|
||||
`From != nil && From.ID != 0`, which is safe for paper-trading state but not for durable
|
||||
Telegram objects.
|
||||
|
||||
The refusal must explain the fix ("sticker packs need a personal account — turn off anonymous
|
||||
posting for this message"), not just deny.
|
||||
|
||||
### Set names
|
||||
|
||||
- `slugRe = ^[a-z][a-z0-9_]{2,39}$` — 3 to 40 chars. Additionally reject `__` (Telegram
|
||||
forbids consecutive underscores) and a trailing `_`. The cap is for link readability and to
|
||||
stay inside the 64-char set-name budget.
|
||||
- `makeSetName(slug, botUsername) (string, error)` → `slug + "_by_" + botUsername`, erroring
|
||||
above 64 chars and reporting the remaining slug budget so the reply can say "max N
|
||||
characters".
|
||||
- **`ownsSet(pack Pack, setName string) bool`** — case-insensitive comparison of `setName`
|
||||
against the stored `pack.Name`. This is the ownership resolver used by Phase 4.
|
||||
|
||||
It deliberately replaces a `parseSlug(setName, botUsername)` design that re-derived the slug
|
||||
from the *live* username and discarded the persisted `Pack.Name`. Renaming the bot in
|
||||
BotFather — supported, and it leaves existing set names untouched — would have made every
|
||||
user's own pack refuse as "not yours" while `/mypack` still displayed it. Comparing the
|
||||
stored name also removes a case-sensitivity trap, since Telegram returns `SetName` with
|
||||
whatever casing the set was created with (plan R8).
|
||||
|
||||
- `usernameResolver` caches `GetMe` and **must not cache failures**. The bot starts with
|
||||
`bot.WithSkipGetMe()` (`internal/telegram/client.go:26`), so nothing populates a username
|
||||
until the module asks. It is used **only** by `/newpack` to name a new set — never for
|
||||
ownership. It takes the handler's `b *bot.Bot`, **not** `deps.Bot`, which is documented
|
||||
nil-safe (`internal/modules/module.go:88`) and is nil under `BuildOptions{}`
|
||||
(`cmd/server/command_menu_test.go:55`).
|
||||
|
||||
### Emoji parsing
|
||||
|
||||
`parseEmoji(args []string) ([]string, error)` — join arguments, then split into clusters:
|
||||
|
||||
- keep ZWJ (`U+200D`) sequences together;
|
||||
- absorb variation selectors (`U+FE0F`/`U+FE0E`), skin-tone modifiers (`U+1F3FB`–`U+1F3FF`),
|
||||
and combining marks into the preceding cluster;
|
||||
- pair regional indicators (`U+1F1E6`–`U+1F1FF`);
|
||||
- keep keycap sequences (`<base> U+FE0F U+20E3`) together.
|
||||
|
||||
Reject non-emoji text with a usage error. Cap at 20 (`emoji_list` is documented 1–20; the
|
||||
server's own message is the literal `too many emoji specified`). `defaultEmoji = "⭐"`.
|
||||
|
||||
Because `/addsticker` now takes only `[emoji...]`, every one of its arguments is an emoji —
|
||||
there is no first-token disambiguation to perform, and a stray word fails loudly here rather
|
||||
than being mistaken for a pack name.
|
||||
|
||||
`models.Sticker.Emoji` is a **single string** (`models/sticker.go:23`), so emoji inherited
|
||||
from a replied sticker yields at most one element.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/sticker/pack.go`, `setname.go`, `sender.go`, `emoji.go`
|
||||
- Create: matching `_test.go` files for each
|
||||
- Reference: `internal/storage/doc_store.go:38` (DocStore contract), `keys.go:24-41`
|
||||
- Reference: `internal/modules/coin/handlers_test.go:36` (memory-store test pattern)
|
||||
- Reference: `models/message.go:86-87` (`SenderChat`), `models/user.go:12` (`IsBot`),
|
||||
`models/sticker.go:23` (`Emoji` is one string)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `pack.go` — record, `packKey`, `getPack`.
|
||||
2. `sender.go` — `senderID` with the bot/anonymous refusals.
|
||||
3. `setname.go` — slug validation, `makeSetName`, `ownsSet`, cached resolver interface.
|
||||
4. `emoji.go` — cluster scanner and `defaultEmoji`.
|
||||
5. Tests per the Todo list.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] Define `Pack` incl. `Count` and `Pending`; assert no reserved-bson collision
|
||||
- [x] `packKey(ownerID)` and `getPack` returning a found flag
|
||||
- [x] `senderID` rejecting nil/zero/`IsBot`/`SenderChat` with an explanatory message
|
||||
- [x] `slugRe` validation incl. `__`, trailing `_`, 40-char cap
|
||||
- [x] `makeSetName` with 64-char guard and budget-reporting error
|
||||
- [x] `ownsSet` case-insensitive match against stored `Pack.Name`
|
||||
- [x] `usernameResolver` caching success but never failure, taking the handler's `b`
|
||||
- [x] `parseEmoji` cluster scanner with the 20-entry cap
|
||||
- [x] Four test files per the success criteria
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `getPack` for owner A never returns owner B's pack
|
||||
- [x] `getPack` on an unknown owner returns `found == false` and a nil error
|
||||
- [x] No `List` call exists anywhere in the module
|
||||
- [x] Slug table rejects leading digit, `__`, trailing `_`, 2 chars, 41 chars
|
||||
- [x] `makeSetName` errors when `len(slug)+len("_by_"+username) > 64`
|
||||
- [x] `ownsSet` matches `MyPack_by_Bot` against a stored `mypack_by_bot`
|
||||
- [x] `ownsSet` returns false for a set name belonging to another bot
|
||||
- [x] A simulated bot username change does **not** break `ownsSet` for an existing pack
|
||||
- [x] `senderID` rejects `IsBot: true` and a non-nil `SenderChat`, each with zero store access
|
||||
- [x] `parseEmoji` handles joined input, ZWJ family, flag, keycap, skin tone; rejects plain text; errors above 20
|
||||
- [x] `gofmt -l internal/modules/sticker` empty; `go test`/`go vet` clean
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Emoji cluster scanning is hand-rolled.** Go has no stdlib grapheme segmentation and a
|
||||
dependency for this is disproportionate. Signal: a user reports an emoji split or rejected.
|
||||
Response: extend the table-driven test with the failing sequence. If failures accumulate
|
||||
across many scripts, take a segmentation dependency.
|
||||
|
||||
**`Count` can drift.** Editing the pack through @Stickers changes the real count without the
|
||||
bot seeing it. Accepted: the field is advisory and feeds one display column. Phase 3 refreshes
|
||||
it whenever a command already holds a `GetStickerSet` response, so it self-heals without any
|
||||
command paying for a lookup it did not otherwise need.
|
||||
|
||||
**Keying on the user ID alone bakes in the one-pack limit.** Reversing to multiple packs later
|
||||
means a key migration, not just new commands — every existing record would need rewriting
|
||||
under a compound key. That is the real cost of plan R10, and it is why the limit belongs in
|
||||
the plan rather than living as a constant someone can bump.
|
||||
@@ -1,275 +0,0 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Phase 3: Pack lifecycle commands"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "7h"
|
||||
dependencies: [1, 2]
|
||||
---
|
||||
|
||||
# Phase 3: Pack lifecycle commands
|
||||
|
||||
## Overview
|
||||
|
||||
`/newpack`, `/mypack`, `/renamepack`, `/delpack` (+ confirm callback). These own creation and
|
||||
destruction of the user's single pack, and they carry the plan's two hardest correctness
|
||||
problems: surviving partial failure, and making an irreversible delete safe to confirm.
|
||||
|
||||
All handlers follow the plan's cross-cutting rules (explicit deadline, `WithoutCancel`
|
||||
commits, `senderID`, positive error classification).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: create the caller's pack and persist its record such that no interruption can
|
||||
permanently strand the slug.
|
||||
- Functional: show, rename, and delete that pack, never another user's.
|
||||
- Functional: a second `/newpack` while a pack exists is refused with a clear next step.
|
||||
- Functional: `/delpack` confirmation is bound to invoker, chat, message, and a TTL.
|
||||
- Functional: `/mypack` makes zero API calls.
|
||||
- Non-functional: a store record must never claim a pack that does not exist, and a live pack
|
||||
must never lose its record because of a transient error.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Factory
|
||||
|
||||
Mirrors `internal/modules/coin/coin.go`. `state` holds `store`, `pending` (a second typed view
|
||||
for delete confirmations — the pattern is idiomatic here; `loldle` and `lol` each build three
|
||||
views over one collection), `resolver`, `locks keylock.Map`, `nowFn`.
|
||||
|
||||
Registry key is `sticker`; command names stay unprefixed — the registry keys commands by
|
||||
`cmd.Name` independent of module name (`registry.go:176`), which is why `misc` ships `/ff`.
|
||||
|
||||
`handlerTimeout = 10 * time.Second` is a package constant; every handler opens with it.
|
||||
|
||||
> **Superseded during implementation — see plan.md, "post-implementation review:
|
||||
> global slug reservation".** Step 5 below adopts an existing set on the strength
|
||||
> of a `Pending` record for this owner and slug. Review proved that insufficient:
|
||||
> the pack record is keyed by owner, so a user with *no* pack who types someone
|
||||
> else's slug produces identical evidence and took over their pack. The shipped
|
||||
> code adds a create-only global reservation (`slug:<slug>` → ownerID) written
|
||||
> before Telegram is touched, and adopts only when it names the caller. Steps 4-7
|
||||
> read as implemented **except** that a reservation check precedes step 4, and
|
||||
> step 5's "abort, delete the pending record" on an unknown error is wrong for
|
||||
> the same reason rule 4 exists — the shipped code keeps both the intent and the
|
||||
> reservation unless the refusal is positively classified.
|
||||
|
||||
### `/newpack <pack> <title...>` — write-ahead intent
|
||||
|
||||
`<pack>` appears here and nowhere else in the module. It fixes the permanent share URL, and
|
||||
Telegram has no rename-short-name method, so it cannot be corrected later.
|
||||
|
||||
An earlier draft did "create on Telegram, then write the store", accepting that an interruption
|
||||
stranded the slug forever. Two review findings killed that: the commit ran on `rootCtx`, which
|
||||
SIGTERM cancels, so **every deploy** during a `/newpack` stranded a slug; and the
|
||||
"cannot adopt" rule was not forced by the API's missing owner field (plan C5). The bot does not
|
||||
need the API to name the owner — it needs its own record of who asked.
|
||||
|
||||
1. `senderID(msg)`; `defer s.locks.Acquire(...)()`.
|
||||
2. Parse slug and title (1–64 chars).
|
||||
3. `makeSetName(slug, username)`.
|
||||
4. **`PutVersioned(ctx, key, 0, Pack{Pending: true, …})`** — the create-only primitive
|
||||
(`doc_store.go:33-37`; Mongo gives a linearizable single-winner via duplicate-key,
|
||||
`mongo_doc_store.go:87-105`). This *is* the one-pack quota — no separate counter exists.
|
||||
On `ErrConflict`, read the record:
|
||||
- confirmed → "you already have a pack (`<slug>`). Use /delpack first." Stop.
|
||||
- `Pending` with the **same** slug → this is our own interrupted attempt; resume at step 5.
|
||||
- `Pending` with a **different** slug → an earlier attempt was interrupted. **Probe
|
||||
`GetStickerSet(oldName)` before doing anything.**
|
||||
- The old set **exists** → the earlier attempt got as far as creating it. Adopt the old
|
||||
set, commit it, and tell the user they already have a pack (`<oldSlug>`) and must
|
||||
`/delpack` first if they want the new name. Do **not** overwrite.
|
||||
- The old set is **missing** (`isStickerSetMissing`) → nothing was created; overwrite the
|
||||
pending record with the new slug and continue.
|
||||
- **Any other error** → unknown; abort without touching the record.
|
||||
|
||||
Overwriting unconditionally would orphan a created-but-uncommitted set permanently: the
|
||||
set exists and is owned by the user, but adoption keys on the pending slug matching, so
|
||||
`/newpack <oldSlug>` would afterwards report "taken" with no route back. The probe is what
|
||||
makes the different-slug branch safe.
|
||||
|
||||
Use `Put` nowhere here — it is a 5-attempt Get→PutVersioned loop
|
||||
(`mongo_doc_store.go:120-142`) that silently overwrites.
|
||||
5. `GetStickerSet(name)`:
|
||||
- **succeeds** → the set exists. We hold a `Pending` record for this owner and slug, so this
|
||||
is our own interrupted attempt: **adopt it**, jump to step 7.
|
||||
- **`isStickerSetMissing`** → the slug is free; proceed to step 6.
|
||||
- **any other error** → unknown. Abort, delete the pending record, reply generic failure.
|
||||
Never guess (plan rule 4).
|
||||
6. `CreateNewStickerSet{UserID, Name, Title, Stickers: []InputSticker{{Sticker: fileID, Format: "static", EmojiList: emoji}}}`.
|
||||
No top-level `sticker_format` — it moved to `InputSticker.Format` in Bot API 7.2 (C7).
|
||||
On `PACK_SHORT_NAME_OCCUPIED`, another user of this bot holds the slug: delete the pending
|
||||
record and ask for a different one.
|
||||
7. Commit: `Put(context.WithoutCancel(ctx), key, Pack{Pending: false, Count: 1, …})`.
|
||||
Reply with the title and `https://t.me/addstickers/<name>`.
|
||||
|
||||
Re-running `/newpack` with the same slug after any interruption completes the operation instead
|
||||
of reporting it taken. That is the plan's "interrupted `/newpack` can be completed by re-running"
|
||||
criterion.
|
||||
|
||||
### `/mypack` — zero API calls
|
||||
|
||||
`getPack(senderID)`. One `Get`. Renders slug, title, `Count`, and the share link, or a short
|
||||
"you don't have a pack yet — `/newpack <name> <title>`" when absent. A `Pending` record renders
|
||||
with an "(incomplete — re-run /newpack)" marker rather than being hidden, so a stranded attempt
|
||||
is visible and fixable.
|
||||
|
||||
This replaces the multi-pack `/packlist`, which issued one `GetStickerSet` per pack. Under plan
|
||||
C2 each call is bounded only by the library's 60s `http.Client` timeout
|
||||
(`bot.go:17-18,75-77`), so ten of them on a serialized dispatcher was a ~10-minute bot-wide
|
||||
freeze from one argument-free public command. That failure mode no longer exists.
|
||||
|
||||
### `/renamepack <title...>`
|
||||
|
||||
`getPack(senderID)`; absent → "you don't have a pack yet". `SetStickerSetTitle{Name, Title}`,
|
||||
then commit the new `Title` under `WithoutCancel`.
|
||||
|
||||
The reply must state the share link is unchanged **and name the route to a different one**:
|
||||
`/delpack` then `/newpack <new-slug>`. This matters more now that the command takes no slug — a
|
||||
user typing "rename" with only a title is even likelier to expect the URL to follow, and it
|
||||
never can. Pointing at the real path turns a dead end into an answer.
|
||||
|
||||
Required elements of the reply: the new title, the unchanged link, and the delete-and-recreate
|
||||
route with its cost stated (the stickers do not come along).
|
||||
|
||||
**Reverse gap:** if the API succeeds and the commit fails, `/mypack` shows a title Telegram no
|
||||
longer has. Cosmetic, self-heals on the next successful rename. Documented, not mitigated.
|
||||
|
||||
### `/delpack` — no argument, bound confirmation
|
||||
|
||||
An earlier draft put the slug in the callback data and re-checked ownership from
|
||||
`CallbackQuery.From.ID`. That defends against *other* users pressing the button and nothing
|
||||
else: the payload never expired, was not bound to a chat or message, and lived in scrollback
|
||||
forever. Three reviewers flagged it independently, and the `stock` module the draft cited as its
|
||||
model already solves it properly.
|
||||
|
||||
With one pack per user the payload needs no slug at all — but it still needs everything else:
|
||||
|
||||
1. `/delpack` resolves the caller's pack, then writes a pending action:
|
||||
`pendingDelete{ID, OwnerID, Slug, ChatID, MessageID, ExpiresAt}` with
|
||||
`pendingDeleteTTL = 10 * time.Minute`. (`stock/pending_dividend.go:12-16,26-34` uses 24h for
|
||||
a non-destructive action; a destructive one earns a shorter window.)
|
||||
|
||||
The confirm prompt must state all four consequences before the tap, since the command itself
|
||||
names nothing:
|
||||
|
||||
- the pack title being deleted;
|
||||
- **the sticker count that will be lost** (`Pack.Count`);
|
||||
- the exact share link that will stop working;
|
||||
- that both are permanent.
|
||||
|
||||
`/delpack` is the sanctioned way to change a pack URL, so this prompt is the last point at
|
||||
which a user learns the stickers do not survive the change. Understating it here is how
|
||||
someone loses 47 stickers expecting a rename.
|
||||
2. Callback data is `sticker_pack:d:<opaque id>` — comfortably inside the 64-byte cap (C3).
|
||||
3. The callback handler:
|
||||
- returns early when `update.CallbackQuery == nil`;
|
||||
- resolves the pending action; absent → "this confirmation expired or was already used"
|
||||
(`stock/dividend_callback.go:66-80` is the model);
|
||||
- checks `query.From.ID == action.OwnerID` — identity from `From.ID`, **never** the payload;
|
||||
- checks the chat/message binding and `ExpiresAt`;
|
||||
- guards `query.Message.Message` for nil — it is a `MaybeInaccessibleMessage`
|
||||
(`models/message.go:17-21`), nil for messages Telegram marks inaccessible.
|
||||
`stock/dividend_callback.go:82-83` already guards exactly this. Phase 1's panic barrier is
|
||||
the backstop, not an excuse to skip the guard;
|
||||
- deletes the pending action **before** calling `DeleteStickerSet` (single-use);
|
||||
- `DeleteStickerSet{Name}`, then `store.Delete` under `WithoutCancel`;
|
||||
- `AnswerCallbackQuery` and clear the button via `EditMessageReplyMarkup` with empty markup,
|
||||
using `action.ChatID`/`action.MessageID` (`dividend_callback.go:26-33`).
|
||||
|
||||
**Reverse gap:** if `DeleteStickerSet` succeeds and `store.Delete` fails, a phantom record
|
||||
survives — and under one-pack-per-user that is worse than before, because it blocks `/newpack`
|
||||
entirely rather than consuming one of ten slots. Mitigation: any command receiving
|
||||
`isStickerSetMissing` from the API deletes the record on the spot, so the phantom clears on
|
||||
first contact and `/newpack` works again.
|
||||
|
||||
### Error mapping
|
||||
|
||||
`replyAPIError` matches **MTProto code substrings**, never human text (plan rule 4 / R3). Only
|
||||
`PACK_SHORT_NAME_OCCUPIED`, `PACK_SHORT_NAME_INVALID`, and `STICKER_EMOJI_INVALID` are rewritten
|
||||
into prose by the Bot API server; everything else arrives as `Bad Request: <CODE>`.
|
||||
|
||||
| Match | Reply |
|
||||
|---|---|
|
||||
| `PACK_SHORT_NAME_OCCUPIED` / "already occupied" | slug taken, pick another |
|
||||
| `PACK_SHORT_NAME_INVALID` / "invalid sticker set name" | slug rejected by Telegram |
|
||||
| `PACK_TITLE_INVALID` | title rejected by Telegram |
|
||||
| `STICKERSET_INVALID` | your pack no longer exists (and delete the record) |
|
||||
| `STICKERS_TOO_MUCH` | pack is full (120 stickers) |
|
||||
| `STICKER_EMOJI_INVALID` / "invalid sticker emojis" | emoji rejected |
|
||||
| `too many emoji specified` | at most 20 emoji per sticker |
|
||||
| anything else | generic failure; raw error to the dispatcher log |
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/sticker/sticker.go`, `state.go`, `pack_handlers.go`,
|
||||
`pending_delete.go`, `delpack_callback.go`, `errors.go`, and their tests
|
||||
- Reference: `internal/modules/coin/coin.go`, `coin/handlers.go:51` (keylock idiom)
|
||||
- Reference: `internal/modules/stock/pending_dividend.go:12-34,47-78`,
|
||||
`stock/dividend_callback.go:17-33,66-83,99-102`, `stock/dividend_notifications.go:303-320`
|
||||
- Reference: `internal/storage/doc_store.go:33-41`, `mongo_doc_store.go:87-142`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `state.go`, `sticker.go` wiring four commands + the `sticker_pack:` callback.
|
||||
2. `errors.go` with `replyAPIError` and `isStickerSetMissing`.
|
||||
3. `/mypack` first — no mutations, no API calls, easiest to verify.
|
||||
4. `/newpack` with the write-ahead state machine.
|
||||
5. `/renamepack`.
|
||||
6. `pending_delete.go`, `/delpack`, and the callback.
|
||||
7. Tests per the Todo list, using Phase 1's `StubMethod` / `FailMethodCode`.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] `state.go` with store, pending view, resolver, locks, nowFn, `handlerTimeout`
|
||||
- [x] `sticker.go` factory registering the module's commands + callback prefix (9 as shipped, once phases 4-5 landed)
|
||||
- [x] `errors.go`: `replyAPIError` code table + `isStickerSetMissing`
|
||||
- [x] `/mypack` reading `Count`, marking a pending record, zero API calls
|
||||
- [x] `/newpack` steps 1-7 incl. `PutVersioned` intent, three `ErrConflict` branches, adoption
|
||||
- [x] Different-slug pending branch probes `GetStickerSet(oldName)` before overwriting
|
||||
- [x] `/renamepack` reply: new title, unchanged link, and the /delpack + /newpack route
|
||||
- [x] `/delpack` confirm prompt: title, sticker count, link, permanence
|
||||
- [x] `pending_delete.go` with TTL, chat/message binding, opaque id
|
||||
- [x] `/delpack` naming the pack in its confirm prompt
|
||||
- [x] `delpack_callback.go` with expiry, binding, nil-message guard, single-use
|
||||
- [x] Record self-heal on `isStickerSetMissing` across commands
|
||||
- [x] Tests per the success criteria
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `/mypack` records **zero** entries in `RecordingBot.Sent()`
|
||||
- [x] A second `/newpack` with a confirmed pack present is refused, names the existing slug, and makes zero API calls
|
||||
- [x] Interrupted `/newpack` (pending record, same slug, set exists) completes on re-run and does not report the slug taken
|
||||
- [x] Interrupted `/newpack` with a *different* slug where the old set **exists** adopts the old set and refuses the new slug, leaving nothing orphaned
|
||||
- [x] Interrupted `/newpack` with a *different* slug where the old set is **missing** replaces the pending record and proceeds
|
||||
- [x] `/newpack` where `GetStickerSet` fails with a non-missing error aborts and never calls `CreateNewStickerSet`, **keeping** the pending record and the reservation — superseded, see the note at the top of this file. Deleting them on an unknown error is what strands a slug: the set may exist, and re-running is how the user recovers.
|
||||
- [x] `/newpack` uses `PutVersioned(…, 0, …)`; the create path never calls `Put` for a new record
|
||||
- [x] `/renamepack` with no pack replies "you don't have a pack yet" and makes zero API calls
|
||||
- [x] `/delpack` confirm after `ExpiresAt` is refused as expired, with no `DeleteStickerSet`
|
||||
- [x] `/delpack` confirm from a different `From.ID` is refused, with no `DeleteStickerSet`
|
||||
- [x] `/delpack` confirm with a nil `CallbackQuery.Message.Message` is handled without panic
|
||||
- [x] Pressing the same confirm twice deletes once; the second press reports already-used
|
||||
- [x] Callback data is asserted ≤ 64 bytes
|
||||
- [x] A command receiving `STICKERSET_INVALID` deletes the stale record, unblocking `/newpack`
|
||||
- [x] Title of 65 chars rejected locally, before any API call
|
||||
- [x] `/delpack` confirm text contains the pack title, the sticker count, and the share link
|
||||
- [x] `/renamepack` reply names the `/delpack` + `/newpack` route
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**The write-ahead state machine is the most intricate logic in the plan**, and one-pack-per-user
|
||||
adds a branch rather than removing one: `ErrConflict` now means three different things
|
||||
(confirmed pack, own pending same-slug, own pending different-slug). Its correctness rests on
|
||||
one property — a `Pending` record for an owner means *that owner* asked for *that name*, and
|
||||
only this bot can create `*_by_<bot_username>` names. If either half stops holding, adoption
|
||||
becomes unsafe. Signal: a user reports adopting a pack they did not create. Response: disable
|
||||
adoption (step 5 becomes "slug taken") and fall back to the documented orphan gap — a one-line
|
||||
change, deliberately.
|
||||
|
||||
**A stranded pending record now blocks the user entirely.** With ten slots it cost one; with one
|
||||
pack it blocks `/newpack` until resolved. Mitigated by making it visible in `/mypack` with a
|
||||
re-run hint, and by the different-slug overwrite branch in step 4 so a user is never wedged by
|
||||
a name they no longer want.
|
||||
|
||||
**`/delpack` remains irreversible on Telegram's side.** The TTL and bindings reduce accidental
|
||||
confirmation; they cannot undo a deliberate one. Do not add a `--force` bypass.
|
||||
@@ -1,240 +0,0 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "Phase 4: Sticker commands (reply path)"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "4h"
|
||||
dependencies: [1, 2, 3]
|
||||
---
|
||||
|
||||
# Phase 4: Sticker commands (reply path)
|
||||
|
||||
## Overview
|
||||
|
||||
Per-sticker operations on the caller's pack: `/addsticker` (existing-sticker source),
|
||||
`/delsticker`, `/editsticker`, `/ordersticker`. All are driven by replying to a sticker. The
|
||||
photo source arrives in Phase 5 through the same `/addsticker` handler.
|
||||
|
||||
None of these takes a pack argument. Under one-pack-per-user there is nothing to name.
|
||||
|
||||
All handlers follow the plan's cross-cutting rules.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: add an existing static sticker to the caller's pack.
|
||||
- Functional: remove, re-emoji, and reposition a sticker already in that pack.
|
||||
- Non-functional: ownership is checked before any API call, and the refusal text is identical
|
||||
for "another user's pack" and "another bot's pack".
|
||||
- Non-functional: no transient error may delete a live pack's record.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Shared resolution
|
||||
|
||||
```go
|
||||
// source of a NEW sticker: whatever the replied message carries. Resolution
|
||||
// always ends in a file_id usable as InputSticker.Sticker.
|
||||
type stickerSource struct {
|
||||
fileID string // static sticker file_id, or a freshly uploaded one (Phase 5)
|
||||
emoji []string // from the replied sticker; at most one element
|
||||
}
|
||||
func (s *state) resolveSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error)
|
||||
|
||||
// an EXISTING sticker in the caller's pack
|
||||
type ownedSticker struct {
|
||||
fileID string
|
||||
pack Pack
|
||||
}
|
||||
func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, error)
|
||||
```
|
||||
|
||||
`resolveSource` takes `ctx`, `b`, and `ownerID` from the start even though the sticker branch
|
||||
uses none of them. Phase 5's photo branch needs all three (`GetFile`, an HTTP download,
|
||||
`UploadStickerFile{UserID}`), and it lives **inside this function**. Declaring the full
|
||||
signature now means Phase 5 adds a branch instead of rewriting every call site. There is no
|
||||
`photoRef` field: the photo path resolves to a `fileID` like every other path.
|
||||
|
||||
Its gate:
|
||||
|
||||
1. Require `msg.ReplyToMessage`; else usage error.
|
||||
2. Sticker branch — reject `IsAnimated`, `IsVideo`, **and `Type != "regular"`**
|
||||
(`models/sticker.go:16`). A mask or custom-emoji sticker is static yet invalid for a
|
||||
regular set, so the `IsAnimated || IsVideo` pair alone does not close this. The static-only
|
||||
gate belongs here, on the path that can actually receive a non-static sticker — not only in
|
||||
`resolveOwned`, which by construction only ever sees stickers already in a static pack.
|
||||
3. Otherwise (Phase 5) the photo/document branch; until then, a usage error.
|
||||
|
||||
`resolveOwned` is the single ownership gate for `/delsticker`, `/editsticker`, `/ordersticker`,
|
||||
and (Phase 5) `/setpackicon`:
|
||||
|
||||
1. Require `msg.ReplyToMessage.Sticker`; else usage error.
|
||||
2. Require a non-empty `Sticker.SetName`.
|
||||
3. Reject `IsAnimated`, `IsVideo`, or `Type != "regular"`. Static-only module; defence in
|
||||
depth. **Before** the store read, so a malformed reply costs nothing and the "rejected
|
||||
before any API call" criterion holds trivially.
|
||||
4. `getPack(ownerID)` — **one `Get`**. Absent → the caller has no pack.
|
||||
5. `ownsSet(pack, sticker.SetName)` (Phase 2) — case-insensitive against the **stored**
|
||||
`Pack.Name`. False → the sticker is not from the caller's pack.
|
||||
6. **Steps 4 and 5 must produce byte-identical reply text.** Distinct messages would let a user
|
||||
probe whether a given set belongs to someone else. Pack *management* refusals stay uniform
|
||||
even though `/newpack` deliberately discloses slug occupancy (plan's Accepted disclosure
|
||||
section) — those are different questions.
|
||||
|
||||
This replaced a `listPacks` + match design; with one pack it collapses to a single `Get` and a
|
||||
string comparison. It still compares against the stored `Pack.Name` rather than a slug
|
||||
re-derived from the live bot username — Phase 2 explains why (plan R8): a BotFather rename would
|
||||
otherwise make the user's own pack refuse as "not yours" while `/mypack` still displayed it.
|
||||
|
||||
`models.Sticker.Emoji` is a single string (`models/sticker.go:23`), so `stickerSource.emoji`
|
||||
from a replied sticker holds at most one element.
|
||||
|
||||
### `/addsticker [emoji...]`
|
||||
|
||||
Reply required. Pack from `getPack`, source from `resolveSource`. Emoji precedence: explicit
|
||||
args → the replied sticker's emoji → `defaultEmoji`.
|
||||
|
||||
**No pack yet** → "you don't have a pack yet — `/newpack <name> <title>`", zero API calls.
|
||||
This reply is deliberately **not** the uniform `resolveOwned` refusal, and the difference is
|
||||
not an oversight: `resolveOwned` is uniform because it answers a question about a set the
|
||||
caller named, which may be someone else's. `/addsticker` answers only "do *you* have a pack",
|
||||
about the caller's own state, and discloses nothing about anyone else. Being helpful here
|
||||
costs no privacy. A `Pending` record counts as no usable pack — same reply, plus the
|
||||
`/mypack` re-run hint from Phase 3.
|
||||
|
||||
Every argument is an emoji — there is no pack token to disambiguate, so a stray word is caught
|
||||
by `parseEmoji` and reported as a usage error rather than silently read as a pack name.
|
||||
|
||||
`AddStickerToSet{UserID: ownerID, Name: pack.Name, Sticker: InputSticker{Sticker: fileID, Format: "static", EmojiList: emoji}}`.
|
||||
|
||||
`UserID` is the pack owner, always the caller — the module never lets a non-owner reach this
|
||||
call. Take the per-user keylock. On success, increment `Pack.Count` and commit under
|
||||
`WithoutCancel`. `STICKERS_TOO_MUCH` maps to "your pack is full (120 stickers)".
|
||||
|
||||
**Unverified premise — settle before writing this handler.** The whole sticker-source path
|
||||
assumes `AddStickerToSet` accepts a `file_id` for a sticker that lives in a set this bot did
|
||||
not create. The Bot API documents `InputSticker.sticker` as accepting "a file_id as a String
|
||||
to send a file that already exists on the Telegram servers", and says nothing further — but
|
||||
unlike C1–C11 this was never checked against the live API, and every other API assumption in
|
||||
this plan was. If Telegram rejects cross-set reuse, this path collapses into Phase 5's
|
||||
machinery (`GetFile` → download → `UploadStickerFile` → use the returned `file_id`), which
|
||||
inverts the 4→5 dependency and is much better known before the handler is written than after.
|
||||
One live call settles it (plan R12).
|
||||
|
||||
### `/delsticker`
|
||||
|
||||
No arguments. `resolveOwned`, then `DeleteStickerFromSet{Sticker: fileID}`. On success,
|
||||
decrement `Pack.Count` (floor 0) and commit under `WithoutCancel`.
|
||||
|
||||
**Take the per-user keylock**, exactly as `/addsticker` does. Both run the same
|
||||
read-modify-write on `Pack.Count`, so locking one and not the other would be a half-measure
|
||||
that only looks safe. `/editsticker` and `/ordersticker` write nothing and take no lock.
|
||||
Under the lock, the commit uses plain `Put` — the lock is what makes the read-modify-write
|
||||
safe, so the `PutVersioned` reasoning from Phase 3 (which guards *creation*, not updates)
|
||||
does not apply.
|
||||
|
||||
Whether removing the final sticker also destroys the set is **not documented** in the Bot API
|
||||
docs or the open-source Bot API server, so the plan depends on neither answer.
|
||||
|
||||
An earlier draft probed with `GetStickerSet` afterwards and deleted the local record when the
|
||||
probe "reported not-found" — but never defined how not-found differs from a failed call, and its
|
||||
own success criterion (`FailMethod` → "record confirmed removed") specified the destructive
|
||||
reading. Under that design a 429, a DNS blip, or a SIGTERM-cancelled context during a routine
|
||||
delete would erase the only record of a live pack. With one pack per user that is strictly
|
||||
worse than it was: the user loses their pack *and* is blocked from `/newpack` until the phantom
|
||||
clears.
|
||||
|
||||
Corrected: **do not probe.** Decrement the count and stop. If the set really is gone, the next
|
||||
command returns `STICKERSET_INVALID`, and the shared handler for that (Phase 3) deletes the
|
||||
record then — a positive signal, per plan rule 4. Simpler and strictly safer.
|
||||
|
||||
**Aftermath of a `Count: 0` pack — state the recovery route.** Not probing means that if
|
||||
Telegram *did* destroy the set, the user is left holding a record for a pack that no longer
|
||||
exists. `/mypack` makes zero API calls by design, so it cannot notice; `/newpack` refuses
|
||||
because a record exists. The user is not wedged — `/delpack` calls `DeleteStickerSet`, gets
|
||||
`STICKERSET_INVALID`, and Phase 3's self-heal drops the record, freeing `/newpack` — but
|
||||
nothing in the plan told them that, and this phase is what creates the situation. So: when
|
||||
the decrement lands on 0, the reply says the pack is now empty, that Telegram may have
|
||||
removed it, and that `/delpack` clears it if `/addsticker` reports the pack is gone.
|
||||
|
||||
This is also why the success criterion below is scoped rather than absolute. `/delsticker`
|
||||
must never delete the `Pack` record on a **transient or unknown** error — that is finding R7,
|
||||
the whole reason the probe was removed. It must still delete it on a **positive**
|
||||
`STICKERSET_INVALID`, which is Phase 3's cross-command self-heal and the mechanism that
|
||||
unwedges the user. An unqualified "never deletes the record" would forbid the fix.
|
||||
|
||||
### `/editsticker <emoji...>`
|
||||
|
||||
`resolveOwned`, `parseEmoji` (at least one required — an empty `emoji_list` is invalid), then
|
||||
`SetStickerEmojiList{Sticker: fileID, EmojiList: emoji}`.
|
||||
|
||||
### `/ordersticker <position>`
|
||||
|
||||
`resolveOwned`, parse a non-negative integer, reject negatives locally. 0-based, stated in the
|
||||
usage text. Do **not** bound the upper end locally — Telegram validates against the current set
|
||||
size and a local copy would go stale. Its error goes through `replyAPIError`.
|
||||
|
||||
`SetStickerPositionInSet{Sticker: fileID, Position: pos}`.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/sticker/resolve.go`, `sticker_handlers.go`, and their tests
|
||||
- Modify: `internal/modules/sticker/sticker.go` (register four commands)
|
||||
- Reference: `internal/modules/util/handlers_test.go:108-118` — an existing test synthesizing
|
||||
`ReplyToMessage` with a `models.Sticker{FileID, FileUniqueID, SetName, Emoji}`. Exactly the
|
||||
fixture shape every test here needs.
|
||||
- Reference: `internal/testutil/update_builders.go` (`NewPrivateMessage`, `NewGroupMessage`)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `resolve.go` with both helpers and the deliberately uniform not-owned reply.
|
||||
2. The four handlers in `sticker_handlers.go`, each opening with `handlerTimeout`.
|
||||
3. Register with `Parameters` per `docs/command-parameter-conventions.md`:
|
||||
`[emoji...]`, none, `<emoji...>`, `<position>`.
|
||||
4. Tests per the Todo list.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] Settle the `file_id`-reuse premise against the live API before writing `/addsticker`
|
||||
- [x] `resolveSource` with the full `(ctx, b, ownerID, msg)` signature, sticker branch only
|
||||
- [x] `resolveSource` static-only gate: `IsAnimated`, `IsVideo`, `Type != "regular"`
|
||||
- [x] `resolveOwned` using `getPack` + `ownsSet`, with the 6-step gate and uniform refusal
|
||||
- [x] `/addsticker` with emoji precedence and `Count` increment
|
||||
- [x] `/addsticker` no-pack and pending-pack replies, zero API calls
|
||||
- [x] `/delsticker` with `Count` decrement, keylock, and **no** probe
|
||||
- [x] `/delsticker` empty-pack reply naming the `/delpack` recovery route
|
||||
- [x] `/editsticker` requiring at least one emoji
|
||||
- [x] `/ordersticker` rejecting negatives locally only
|
||||
- [x] Register all four with `Parameters` metadata
|
||||
- [x] `resolve_test.go`, `sticker_handlers_test.go`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Each command's happy path asserts the expected method in `RecordingBot.Sent()`
|
||||
- [x] Missing reply, non-sticker reply, and empty `set_name` each produce a usage error with zero API calls
|
||||
- [x] "No pack yet" and "sticker from another bot's set" produce **byte-identical** reply text, asserted by comparing the two replies to each other
|
||||
- [x] A sticker whose `SetName` differs only in case from the stored `Pack.Name` resolves successfully
|
||||
- [x] `/addsticker` with a non-emoji argument is rejected by `parseEmoji`, not silently reinterpreted
|
||||
- [x] `/ordersticker -1` rejected locally; `/ordersticker 999` reaches the API
|
||||
- [x] `/editsticker` with no emoji rejected locally
|
||||
- [x] `/delsticker` makes exactly one API call, and keeps the `Pack` record on a transient or unknown error
|
||||
- [x] `/delsticker` receiving `STICKERSET_INVALID` **does** delete the record (Phase 3 self-heal), unblocking `/newpack`
|
||||
- [x] `/addsticker` and `/delsticker` move `Count` by exactly one, floored at 0
|
||||
- [x] A `/delsticker` that lands on `Count: 0` names `/delpack` in its reply
|
||||
- [x] `/addsticker` with no pack, and with a `Pending` pack, each reply with zero API calls
|
||||
- [x] `/addsticker` on a full pack maps `STICKERS_TOO_MUCH` to the "pack is full" reply (via Phase 1 `FailMethodCode`)
|
||||
- [x] Animated, video, **and mask/custom-emoji** (`Type != "regular"`) replies rejected before any API call, on both the source and owned paths
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**The uniform-refusal requirement is easy to regress.** A later contributor improving the error
|
||||
copy could split the two messages and reintroduce the disclosure. Mitigation: the test asserts
|
||||
equality *between the two paths' replies* rather than asserting two fixed strings, so the intent
|
||||
survives a rewrite of the copy.
|
||||
|
||||
**`resolveOwned` now costs a single `Get`** — no `List`, no fan-out, no API call. This is the
|
||||
one place the one-pack revision made a correctness-critical path cheaper as well as simpler,
|
||||
and it removes the caching follow-up the multi-pack version needed.
|
||||
|
||||
**`Count` drift is user-visible but harmless.** Editing the pack through @Stickers desyncs it.
|
||||
Phase 3 refreshes it whenever a command already holds a `GetStickerSet` response, so it
|
||||
self-heals without any command paying for a lookup it did not otherwise need.
|
||||
@@ -1,212 +0,0 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Phase 5: Photo pipeline and pack icon"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "8h"
|
||||
dependencies: [1, 2, 3, 4]
|
||||
---
|
||||
|
||||
# Phase 5: Photo pipeline and pack icon
|
||||
|
||||
## Overview
|
||||
|
||||
Turn a replied-to photo (or image document) into a valid static sticker, and add
|
||||
`/setpackicon`, which needs the same resizing machinery at a different output size. This is
|
||||
the only phase doing network I/O and CPU work, and the only one handling the bot token.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: a replied photo or image document becomes a sticker with a 512px long edge and
|
||||
a preserved aspect ratio.
|
||||
- Functional: `/setpackicon` sets a pack's thumbnail from a sticker already in that pack.
|
||||
- Non-functional: bounded in bytes and time — it blocks every other user while it runs (C1).
|
||||
- Non-functional (**security**): the download URL embeds the bot token and must never reach a
|
||||
log, a reply, or a returned error.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Bounds
|
||||
|
||||
Plan rule 1 already puts `handlerTimeout = 10s` on every handler, which is the outer bound.
|
||||
This phase adds:
|
||||
|
||||
| Bound | Value | Why |
|
||||
|---|---|---|
|
||||
| Source file size | reject above **2 MB** before downloading | Telegram-compressed `photo` sizes are typically well under 500 KB |
|
||||
| Decoded dimensions | reject above 4096×4096 via `DecodeConfig` | Bounds peak allocation before any pixel buffer exists |
|
||||
| HTTP client | explicit per-request timeout, not `http.DefaultClient` | The library's shared client is 60s (`bot.go:17-18`) — too long to inherit |
|
||||
|
||||
Worst case is a ~10s bot-wide stall (C1). Bounded and observable, not zero.
|
||||
|
||||
### Telegram's static-sticker format
|
||||
|
||||
PNG or WEBP; **one side exactly 512px**, the other ≤512px. Pack thumbnails differ: PNG or
|
||||
WEBP, exactly **100×100**, ≤128 KB.
|
||||
|
||||
There is no documented file-size limit for static stickers — the widely-repeated 512 KB
|
||||
figure appears in no current official page (plan R4). It is a client-side ceiling only and
|
||||
must not be described as spec in code comments or user-facing text.
|
||||
|
||||
### Source selection
|
||||
|
||||
This is the photo branch of Phase 4's `resolveSource(ctx, b, ownerID, msg)`, which already
|
||||
takes every parameter this branch needs — no call site changes, and no `photoRef` type: the
|
||||
branch resolves to a `fileID` like the sticker branch, by uploading first.
|
||||
|
||||
From `msg.ReplyToMessage`:
|
||||
|
||||
- `Photo []PhotoSize` — pick the largest by `FileSize`; do not rely on Telegram's ordering.
|
||||
- `Document` — accept only `MimeType` of `image/png`, `image/jpeg`, `image/webp`. Reject
|
||||
anything else *before* downloading.
|
||||
|
||||
Reject when `FileSize > 2<<20`.
|
||||
|
||||
### Download — and the token-leak trap
|
||||
|
||||
`GetFile{FileID}` → `b.FileDownloadLink(f)` (`bot.go:180-182`) → `http.Get`.
|
||||
|
||||
`FileDownloadLink` returns `https://api.telegram.org/file/bot<TOKEN>/<path>`. **Every
|
||||
transport failure from `http.Client.Do` returns a `*url.Error` whose `Error()` embeds the
|
||||
full URL**, and `internal/modules/dispatcher.go:136-138` logs a handler's returned error
|
||||
verbatim. A timeout mid-transfer — trivially reachable — would therefore print the bot token
|
||||
to stdout, the Coolify log store, and any log shipper.
|
||||
|
||||
The earlier draft's mitigation ("log the `file_id` instead") covered only deliberate logging
|
||||
and missed this path entirely; its success criterion would have passed while the leak shipped.
|
||||
|
||||
Correct handling, per plan rule 5 — **no error from this package may escape raw**:
|
||||
|
||||
```go
|
||||
var errDownloadFailed = errors.New("sticker: download failed")
|
||||
|
||||
// ...
|
||||
if err != nil {
|
||||
log.Error("sticker download", "file_id", fileID, "reason", classify(err))
|
||||
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
|
||||
}
|
||||
```
|
||||
|
||||
The original error is discarded, never wrapped — wrapping would keep the URL reachable
|
||||
through `errors.Unwrap` and `%v`. `classify(err)` maps to a coarse label (`timeout`,
|
||||
`transport`, `status`) that cannot contain a URL.
|
||||
|
||||
Other rules:
|
||||
|
||||
- `io.LimitReader(body, 2<<20)`; never trust `Content-Length`.
|
||||
- Read fully into memory — bounded at 2 MB, so no temp files.
|
||||
|
||||
### Decode / resize / encode
|
||||
|
||||
`toStickerPNG(src []byte) ([]byte, error)`:
|
||||
|
||||
1. `image.DecodeConfig` first — reject above 4096×4096 before allocating pixels.
|
||||
2. `image.Decode` with `image/jpeg`, `image/png`, `image/gif` registered, plus
|
||||
`golang.org/x/image/webp` (read-only decoder, same module).
|
||||
3. Scale so the long edge is exactly 512 and the short edge is `round(short*512/long)`,
|
||||
clamped to ≥1. A square input yields 512×512.
|
||||
4. `draw.CatmullRom.Scale` into a fresh `*image.NRGBA` — preserves alpha.
|
||||
5. `png.Encode`. Above the 512 KB client-side ceiling, retry with
|
||||
`png.Encoder{CompressionLevel: png.BestCompression}`; if still over, step the long edge
|
||||
down (448, 384, 320). Give up after 320.
|
||||
|
||||
`toThumbnailPNG(src []byte) ([]byte, error)` runs the same pipeline to exactly 100×100,
|
||||
padding the short edge with transparency to preserve aspect ratio.
|
||||
|
||||
Both are pure functions over `[]byte` so they test without a network.
|
||||
|
||||
### Upload
|
||||
|
||||
`UploadStickerFile{UserID: ownerID, Sticker: &models.InputFileUpload{Filename: "sticker.png", Data: bytes.NewReader(png)}, StickerFormat: "static"}`
|
||||
→ use the returned `File.FileID` as `InputSticker.Sticker`.
|
||||
|
||||
Two steps, not stylistic: plan C6 shows the form builder honours `attach://` only for
|
||||
`[]models.InputSticker`, so the single `InputSticker` in `AddStickerToSetParams` cannot carry
|
||||
raw bytes; `*models.InputFileUpload` *is* handled (`build_request_form.go:87`).
|
||||
`uploadStickerFile` still takes `sticker_format` even though `createNewStickerSet` lost its
|
||||
top-level equivalent in Bot API 7.2.
|
||||
|
||||
The returned `file_id` is consumed immediately, so its undocumented validity window never
|
||||
matters. Do not restructure into upload-now-use-later.
|
||||
|
||||
### `/setpackicon`
|
||||
|
||||
No arguments; reply to a sticker in the caller's pack.
|
||||
|
||||
1. `resolveOwned` (Phase 4).
|
||||
2. `GetFile` + download that sticker's image, then `toThumbnailPNG`.
|
||||
3. `SetStickerSetThumbnail{Name: pack.Name, UserID: ownerID, Thumbnail: &models.InputFileUpload{...}, Format: "static"}`.
|
||||
|
||||
The API does accept a `file_id` string for `thumbnail` — the only documented restriction bars
|
||||
HTTP URLs for animated/video. The reason to resize is the documented 100×100 requirement,
|
||||
which a 512px sticker's `file_id` does not meet. Confirm in Phase 6's smoke test; if a raw
|
||||
`file_id` is accepted and auto-resized, this collapses to one call.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `go.mod`, `go.sum` — add `golang.org/x/image`
|
||||
- Create: `internal/modules/sticker/download.go`, `image.go`, `setpackicon.go`,
|
||||
`download_test.go`, `image_test.go`
|
||||
- Modify: `internal/modules/sticker/resolve.go` (photo branch), `sticker_handlers.go`
|
||||
(`/addsticker` photo path), `pack_handlers.go` (`/newpack` photo path), `sticker.go`
|
||||
- Reference: `go-telegram/bot@v1.20.0` `bot.go:180-182`, `build_request_form.go:87,105`
|
||||
- Reference: `internal/modules/dispatcher.go:136-138` (the log path the sentinel protects)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `go get golang.org/x/image`; confirm a direct require and a clean `go mod tidy`.
|
||||
2. `download.go` — bounded fetch, own client timeout, sentinel error conversion.
|
||||
3. `image.go` — `toStickerPNG`, `toThumbnailPNG`.
|
||||
4. Wire the photo branch into `resolveSource`, then `/addsticker` and `/newpack`.
|
||||
5. `setpackicon.go` + registration.
|
||||
6. Tests per the Todo list.
|
||||
|
||||
## Todo
|
||||
|
||||
- [x] Add `golang.org/x/image`; verify `go mod tidy` produces no diff
|
||||
- [x] `download.go` with 2 MB `LimitReader`, own client timeout, sentinel conversion
|
||||
- [x] `classify(err)` returning a coarse label that cannot contain a URL
|
||||
- [x] `toStickerPNG` with DecodeConfig guard, CatmullRom scale, PNG size ladder
|
||||
- [x] `toThumbnailPNG` at exactly 100×100 with transparent padding
|
||||
- [x] Photo/document source selection with mime allowlist and 2 MB pre-check
|
||||
- [x] Wire photo branch into `/addsticker` and `/newpack`
|
||||
- [x] `/setpackicon` handler and registration
|
||||
- [x] `image_test.go` with in-test generated fixtures (no committed binaries)
|
||||
- [x] `download_test.go` asserting no token or URL in any returned error
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] 1024×512 → 512×256; 300×900 → 171×512; 512×512 → 512×512
|
||||
- [x] 1×5000 extreme aspect: short edge clamped to ≥1, no panic, no zero-dimension image
|
||||
- [x] Alpha channel preserved through the resize
|
||||
- [x] Source above 2 MB rejected with zero HTTP requests made
|
||||
- [x] Decoded dimensions above 4096×4096 rejected before pixel allocation
|
||||
- [x] Unsupported document mime rejected before download
|
||||
- [x] `toThumbnailPNG` output is exactly 100×100
|
||||
- [x] **A forced transport failure against an `httptest` server yields an error whose text contains neither `"bot"` nor the URL** — asserted, not assumed
|
||||
- [x] `go mod tidy && git diff --exit-code go.mod go.sum` clean
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**R1 — bot-wide stall.** Under C1 every photo request blocks all users for up to
|
||||
`handlerTimeout`. Tight bounds cap the damage but do not remove it; a user in a loop can keep
|
||||
the bot substantially stalled.
|
||||
|
||||
- Signal: reply latency for unrelated commands spikes with image traffic.
|
||||
- Response, in order: (a) lower `handlerTimeout` and the 2 MB cap; (b) offload the pipeline
|
||||
to a detached goroutine that acks immediately and replies on completion, mirroring
|
||||
`dispatcher.go:80-84`; (c) if neither suffices, revisit the Public visibility decision with
|
||||
the user — their call, not a unilateral change.
|
||||
- **If (b) is ever taken, C1's single-in-flight guarantee disappears**, and two things become
|
||||
mandatory together: a package-level semaphore around image decoding, and a real look at the
|
||||
`/newpack` quota check, which becomes genuinely racy rather than merely lock-protected. The
|
||||
two are linked deliberately so neither is done without the other.
|
||||
|
||||
**Untrusted image decoding.** Bounded by size and dimension checks; Go's decoders are
|
||||
memory-safe. Peak allocation is ~64 MB per conversion at the 4096² cap, and C1 guarantees one
|
||||
at a time. Phase 1's panic barrier is the backstop for a decoder panic — but it is a backstop,
|
||||
not a licence to skip the dimension guard.
|
||||
|
||||
**New dependency.** `golang.org/x/image` is the only one in the plan and the repo's first
|
||||
*direct* `golang.org/x/*` requirement. Confined to `image.go`. If resampling disappoints,
|
||||
swapping `CatmullRom` for `ApproxBiLinear` is one line (plan R9).
|
||||
@@ -1,203 +0,0 @@
|
||||
---
|
||||
phase: 6
|
||||
title: "Phase 6: Wiring, menu, docs"
|
||||
status: partial
|
||||
priority: P2
|
||||
effort: "4h"
|
||||
dependencies: [1, 2, 3, 4, 5]
|
||||
---
|
||||
|
||||
# Phase 6: Wiring, menu, docs
|
||||
|
||||
## Overview
|
||||
|
||||
Register the module, make enabling it an explicit operator decision, and bring the user-facing
|
||||
surfaces named in `AGENTS.md` § "Command Changes" into line. Nothing from Phases 2–5 is
|
||||
reachable by a user until this phase lands.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: the module is enabled only by an explicit `MODULES` entry.
|
||||
- Functional: all nine commands appear in `/help` and the native menu with correct metadata.
|
||||
- Non-functional: `/help` stays under the 4096-rune ceiling the test suite enforces.
|
||||
- Non-functional: no stats migration — nothing is renamed or deleted.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Enablement must come before registration
|
||||
|
||||
`internal/modules/registry.go:107-116` expands an empty `MODULES` to **every** registered
|
||||
factory, and its own comment calls that the documented contract. The repo ships
|
||||
`.env.example:16` as `MODULES=` (empty) and `compose.yml:14` documents "empty = all modules".
|
||||
|
||||
So adding `"sticker": sticker.New` to `factories()` **is** the enablement: a public,
|
||||
write-capable module would go live on the next deploy with no operator decision. An earlier
|
||||
draft claimed the opposite in three places — goal, requirement, and a success criterion a
|
||||
reviewer would have ticked without testing.
|
||||
|
||||
Ordered fix, per the user's decision:
|
||||
|
||||
1. **First**, set `MODULES` explicitly in the deployed environment to the current eleven
|
||||
modules, and verify the bot restarts with an unchanged command set:
|
||||
`util,misc,amlich,monkeyd,wordle,loldle,lol,stock,gold,coin,stats`
|
||||
2. **Then** add the `factories()` entry and merge.
|
||||
3. Add `sticker` to `MODULES` when the operator chooses to turn it on.
|
||||
|
||||
Only after step 1 is "remove `sticker` from `MODULES`" a genuine zero-deploy rollback. Before
|
||||
it, that rollback means enumerating eleven module names into an empty variable under pressure.
|
||||
|
||||
Update `.env.example` with the explicit list and a comment stating why, so a fresh clone does
|
||||
not reintroduce the empty-means-everything trap.
|
||||
|
||||
### Registration
|
||||
|
||||
One line in `factories()` (`cmd/server/main.go:83`). Plain string key, matching `util`, `misc`,
|
||||
and `gold`; the `CollectionName` constant form in `lol`/`coin`/`stock` exists because those
|
||||
packages reuse the name elsewhere, which this one does not.
|
||||
|
||||
`Build` validates names against `^[a-z0-9_]{1,32}$` (`validate.go:10`) and rejects duplicates
|
||||
(`registry.go:173`). All nine were verified free against the current registry; re-run registry
|
||||
tests to catch later additions. Note `mypack` replaced `packlist` in the one-pack revision —
|
||||
re-verify that name specifically, since it was not part of the original conflict check.
|
||||
|
||||
### The `/help` rune budget
|
||||
|
||||
`cmd/server/command_menu_test.go:110-113` renders the full `/help` body and `t.Fatalf`s above
|
||||
`telegramMessageMaxRunesForTest = 4096` (`:116-119`).
|
||||
|
||||
Measured at HEAD: **3212 runes, 45 public commands, 11 modules — 884 runes of headroom.**
|
||||
|
||||
The one-pack revision helps here. Each help line is
|
||||
`InvocationSentence() + " " + SummarySentence()` (`internal/modules/command_presentation.go:16-24`),
|
||||
so `Parameters` counts against the budget — and four commands lost their `<pack>` token:
|
||||
|
||||
| Command | Was | Now |
|
||||
|---|---|---|
|
||||
| `/addsticker` | `<pack> [emoji...]` | `[emoji...]` |
|
||||
| `/renamepack` | `<pack> <title...>` | `<title...>` |
|
||||
| `/delpack` | `<pack>` | — |
|
||||
| `/packlist` → `/mypack` | — | — |
|
||||
|
||||
That is roughly 25 runes recovered across the module, leaving about **98 runes per command
|
||||
line** including the module header rather than ~85. Still not generous: `/ordersticker <position>.`
|
||||
alone is 24 runes, leaving ~74 for its description.
|
||||
|
||||
Write the nine descriptions against that budget *before* wiring, then re-measure — the figure
|
||||
above is derived, not measured post-change. If they do not fit, decide then whether `/help`
|
||||
needs pagination; that is a separate change, and it must not be "solved" by trimming other
|
||||
modules' descriptions.
|
||||
|
||||
### Test impact — which tests, and which only look related
|
||||
|
||||
| Test | Uses real `factories()`? | Action |
|
||||
|---|---|---|
|
||||
| `main_test.go:111` `TestFactoriesIncludesExpectedModules` | No — builds only `{"gold","coin"}` | **No change needed** |
|
||||
| `command_menu_test.go:19` `TestBotCommandMenu_...ModuleOrder` | No — synthetic `alpha`/`beta` | **No change needed** |
|
||||
| `command_menu_test.go:54` `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` | **Yes** — `modules.Build(nil, factories(), …)` | **Add all nine to `expectedParameters`**; also enforces ≤256-rune descriptions, no `Eg:`, no newlines, and the 4096-rune `/help` ceiling |
|
||||
| `command_menu_test.go:121` `TestBotCommandMenu_StockDividendContracts` | Stock-specific | No change |
|
||||
|
||||
`expectedParameters` entries for the nine: `newpack` → `<pack> <title...>`, `addsticker` →
|
||||
`[emoji...]`, `delsticker` → ``, `editsticker` → `<emoji...>`, `ordersticker` → `<position>`,
|
||||
`setpackicon` → ``, `renamepack` → `<title...>`, `delpack` → ``, `mypack` → ``.
|
||||
|
||||
**`<name...>` is not yet a documented form.** `docs/command-parameter-conventions.md:15-20`
|
||||
defines `<name>`, `<name,...>`, `[name]`, and `[name...]` — required *remaining text* appears
|
||||
nowhere, and no existing command uses it (`rg "Parameters:"` across `internal/` confirms).
|
||||
Three of the nine (`<title...>` twice, `<emoji...>`) need it. Add the row
|
||||
`| Required remaining text | \`<name...>\` | \`<title...>\` |` to that table and an example
|
||||
line, in the same change that registers the commands — the conventions doc is the authority
|
||||
these registrations are validated against, so shipping an undocumented form silently
|
||||
demotes it.
|
||||
|
||||
### README and docs
|
||||
|
||||
Module table row listing the nine commands, then a `### Sticker packs` section: **one pack per
|
||||
user**; the pack is created on behalf of the calling user; the bot manages only packs it
|
||||
created; the slug is chosen once at `/newpack` and fixes a permanent share link that
|
||||
`/renamepack` cannot change; static stickers and photos only; the 120-stickers-per-pack cap;
|
||||
anonymous group admins are not supported and why.
|
||||
|
||||
Note the relationship to `/stickerid` in `util` — it stays put. It is a private debug helper for
|
||||
reading a `file_id`, not pack management.
|
||||
|
||||
`docs/sticker-packs.md` carries: the full command reference with `Parameters` matching handler
|
||||
usage text exactly (`docs/command-parameter-conventions.md` § Change Checklist); slug rules and
|
||||
the permanence of the resulting URL; the image contract (512px long edge, PNG; thumbnails
|
||||
100×100); what is deliberately absent and why (usage-statistics commands — no Bot API support;
|
||||
animated/video/emoji packs — out of scope; multiple packs per user — see plan R10;
|
||||
conversational flow — no message hook at `internal/modules/dispatcher.go:66`); the accepted
|
||||
slug-occupancy disclosure; and the `Count` drift note.
|
||||
|
||||
### Stats compatibility
|
||||
|
||||
No command is renamed or deleted **in the shipped bot** — `/packlist` never existed outside this
|
||||
plan, so its replacement by `/mypack` needs no migration. `AGENTS.md` § "Stats Compatibility"
|
||||
governs renames of live commands and does not apply. New commands accrue stats through the
|
||||
dispatcher hook (`dispatcher.go:80-84`).
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: deployed environment `MODULES` (**before** the code change), `.env.example`
|
||||
- Modify: `cmd/server/main.go` (factory entry + import)
|
||||
- Modify: `cmd/server/command_menu_test.go` (`expectedParameters`, +9)
|
||||
- Modify: `README.md`
|
||||
- Create: `docs/sticker-packs.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Set `MODULES` explicitly in the deployed environment; verify an unchanged command set.
|
||||
2. Draft the nine descriptions against the ~98-rune budget; measure `RenderHelp` locally.
|
||||
3. Add the factory entry and import.
|
||||
4. Update `expectedParameters`; run `go test ./cmd/server/...`.
|
||||
5. README row + section; `docs/sticker-packs.md`.
|
||||
6. Full gate, then the manual smoke sequence.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Set `MODULES` explicitly in the deployed environment and verify
|
||||
- [x] Update `.env.example` with the explicit list and a why-comment
|
||||
- [x] Confirm `mypack` is free in the registry alongside the other eight
|
||||
- [x] Draft nine descriptions within the measured budget and re-measure `RenderHelp`
|
||||
- [x] Add `"sticker": sticker.New` to `factories()`
|
||||
- [x] Add nine entries to `expectedParameters`
|
||||
- [x] Add the `<name...>` row + example to `docs/command-parameter-conventions.md`
|
||||
- [x] README module-table row + `### Sticker packs` section
|
||||
- [x] `docs/sticker-packs.md`
|
||||
- [x] Full validation gate
|
||||
- [ ] Manual smoke sequence against a real token
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `gofmt -l .` empty; `go vet ./...` clean; `go test ./...` passes; `golangci-lint run` clean
|
||||
- [x] `RenderHelp` stays under 4096 runes with all nine commands registered
|
||||
- [ ] With `MODULES` unset in a scratch environment, the module still loads — confirming C10 is understood rather than assumed away
|
||||
- [ ] With the explicit `MODULES` list and no `sticker` entry, none of the nine commands register
|
||||
- [ ] Smoke: reply to a sticker with `/newpack smoke_pack Smoke Pack`; the returned link opens
|
||||
- [ ] Smoke: a second `/newpack` is refused and names the existing pack
|
||||
- [ ] Smoke: reply to a photo with `/addsticker 😂` — no pack named — and the sticker renders undistorted
|
||||
- [ ] Smoke: `/mypack`, `/renamepack New Title`, `/setpackicon`, `/ordersticker 0` all succeed
|
||||
- [ ] Smoke: `/renamepack` leaves the share link working and unchanged
|
||||
- [ ] Smoke: a **second** account replying to the first account's sticker with `/delsticker` is refused
|
||||
- [ ] Smoke: an anonymous group admin is refused with the explanatory message
|
||||
- [ ] Smoke: `/delpack` confirm prompt shows the title, sticker count, and link before confirming
|
||||
- [ ] Smoke: `/delpack` → confirm → link 404s; a second press reports already-used; `/newpack` then works again
|
||||
- [ ] Smoke: **after deleting, attempt `/newpack` with the same slug** — this settles plan R11. Record the outcome in `docs/sticker-packs.md` either way, and if the slug is reserved, add that to `/delpack`'s confirm text
|
||||
- [ ] Smoke: `/renamepack` reply names the delete-and-recreate route
|
||||
- [ ] Smoke: observed error strings for an occupied slug and a full pack match `replyAPIError`, or the table is corrected
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**The error-code table is unverified until this phase.** Only three MTProto codes are rewritten
|
||||
into prose by the Bot API server; the rest were inferred from the open-source server's rewrite
|
||||
table, not a live reproduction (plan R3). The smoke sequence deliberately includes an
|
||||
occupied-slug and a full-pack case. A mismatch is a polish gap, not a blocker — the generic
|
||||
fallback means users see a sane message either way.
|
||||
|
||||
**Manual smoke is the only Telegram-side coverage.** Every automated test uses `RecordingBot`,
|
||||
which never contacts Telegram. CI cannot catch a wrong parameter name or a rejected image
|
||||
format. The smoke sequence is mandatory before announcing the feature.
|
||||
|
||||
**Enablement ordering is a process risk, not a code one.** If step 1 is skipped and the factory
|
||||
entry merges first, the module goes live unannounced. Signal: the deployed bot answers
|
||||
`/mypack` before anyone enabled it. Response: set `MODULES` immediately; the module is otherwise
|
||||
harmless until someone runs a command.
|
||||
@@ -1,588 +0,0 @@
|
||||
---
|
||||
title: "Sticker packs module"
|
||||
description: "internal/modules/sticker — public, one-pack-per-user Telegram sticker set management via single-shot reply commands using @Stickers command names"
|
||||
status: partial
|
||||
priority: P2
|
||||
effort: ""
|
||||
tags: ["sticker", "telegram-bot", "module"]
|
||||
created: 2026-08-24
|
||||
branch: main
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# Sticker packs module
|
||||
|
||||
## Overview
|
||||
|
||||
New module `internal/modules/sticker` letting **any** user create and manage **one**
|
||||
personal Telegram sticker pack through the bot. The pack is created on behalf of the calling
|
||||
user (`user_id`), named `<slug>_by_<bot_username>`, and stays bot-manageable because the bot
|
||||
created it.
|
||||
|
||||
One pack per user is the central simplification: no command except `/newpack` takes a pack
|
||||
argument, because there is only ever one pack to act on.
|
||||
|
||||
Command names mirror @Stickers (`/newpack`, `/addsticker`, …) but each command is
|
||||
**single-shot**: one message carrying its arguments, optionally replying to a sticker or
|
||||
photo. Commands are **unprefixed**, like the `misc` module (`/ff`, `/random`).
|
||||
|
||||
Phase 1 fixes two shared-code gaps this module would otherwise expose. They are
|
||||
prerequisites, not incidental work.
|
||||
|
||||
## Goals
|
||||
|
||||
| # | Goal | Priority |
|
||||
|---|------|----------|
|
||||
| 1 | Any user can create and fill their personal sticker pack without leaving the chat | P1 |
|
||||
| 2 | No command but `/newpack` requires naming a pack | P1 |
|
||||
| 3 | Ownership is enforced structurally — a user can never mutate another's pack | P1 |
|
||||
| 4 | No sticker command can stall the bot for other users beyond a bounded deadline | P1 |
|
||||
| 5 | A partial failure never permanently strands a user's pack | P1 |
|
||||
| 6 | Command names and semantics recognisable to @Stickers users | P2 |
|
||||
| 7 | Enabling the module is an explicit operator decision | P2 |
|
||||
|
||||
## Accepted scope
|
||||
|
||||
| Decision | Value |
|
||||
|---|---|
|
||||
| Visibility | `VisibilityPublic` — every user manages their own packs |
|
||||
| Pack model | **One pack per user.** The slug is chosen at creation and never used as an argument again |
|
||||
| Inputs | Existing static stickers (reply) + photos/image documents (reply) |
|
||||
| Interaction | Single-shot reply + args; no conversation state, no `/cancel` |
|
||||
| Naming | @Stickers command names, no module prefix |
|
||||
|
||||
## Command surface
|
||||
|
||||
All `VisibilityPublic`. `Parameters` follows `docs/command-parameter-conventions.md`.
|
||||
|
||||
| Command | Parameters | Reply required | API calls |
|
||||
|---|---|---|---|
|
||||
| `/newpack` | `<pack> <title...>` | yes (sticker/photo) | `GetStickerSet`, `UploadStickerFile`*, `CreateNewStickerSet` |
|
||||
| `/addsticker` | `[emoji...]` | yes (sticker/photo) | `UploadStickerFile`*, `AddStickerToSet` |
|
||||
| `/delsticker` | — | yes (sticker in own pack) | `DeleteStickerFromSet` |
|
||||
| `/editsticker` | `<emoji...>` | yes (sticker in own pack) | `SetStickerEmojiList` |
|
||||
| `/ordersticker` | `<position>` | yes (sticker in own pack) | `SetStickerPositionInSet` |
|
||||
| `/setpackicon` | — | yes (sticker in own pack) | `GetFile`, `SetStickerSetThumbnail` |
|
||||
| `/renamepack` | `<title...>` | no | `SetStickerSetTitle` |
|
||||
| `/delpack` | — | no (inline confirm) | `DeleteStickerSet` |
|
||||
| `/mypack` | — | no | **none** — count comes from the store |
|
||||
|
||||
`*` only on the photo path.
|
||||
|
||||
### Slug is a name, not an address
|
||||
|
||||
`<pack>` survives on `/newpack` alone, where it fixes the permanent share URL
|
||||
`t.me/addstickers/<slug>_by_<bot>`. Telegram has no rename-short-name method, so that choice
|
||||
is unfixable afterwards — which is exactly why it stays user-chosen rather than derived from
|
||||
a user ID (which would publish the owner's numeric Telegram ID forever) or generated opaquely.
|
||||
|
||||
Every other command resolves the caller's single pack from the store, or from the replied
|
||||
sticker's `set_name` matched against it. This supersedes the earlier multi-pack design in
|
||||
which `<pack>` was an argument to `/addsticker`, `/renamepack`, and `/delpack`.
|
||||
|
||||
### Dropped from @Stickers
|
||||
|
||||
`/stats`, `/top`, `/packstats`, `/packtop`, `/topbypack`, `/packusagetop` report sticker
|
||||
**usage counts**, which the Bot API does not expose. `/stats` is also already owned by the
|
||||
`stats` module. `/newanimated`, `/newvideo`, `/newemojipack`, `/newmasks` are outside the
|
||||
accepted static-only scope. `/cancel` is meaningless without conversation state.
|
||||
|
||||
## Architecture constraints (verified against this repo and the live API)
|
||||
|
||||
Each was checked against source, not assumed. C1–C8 survived adversarial review; C5 was
|
||||
corrected.
|
||||
|
||||
### C1 — Handlers are globally serialized
|
||||
|
||||
`internal/telegram/client.go:27` passes `bot.WithNotAsyncHandlers()`, and the library's
|
||||
`defaultWorkers = 1` (`bot.go:20`) with no `WithWorkers` override. `process_update.go:26-28`
|
||||
runs the handler inline. One slow handler stalls **every** user.
|
||||
|
||||
Caveat found in review: "one update at a time" is not "one goroutine". The cron scheduler
|
||||
(`cmd/server/main.go:163`) and the detached per-command stats hook
|
||||
(`internal/modules/dispatcher.go:80-84`) both run concurrently with handlers. Neither
|
||||
touches pack state today, but the per-user keylock is therefore **not** redundant.
|
||||
|
||||
### C2 — No per-update deadline, and the library's own ceiling is 60s
|
||||
|
||||
Handler ctx is `rootCtx` (`cmd/server/main.go:107,214`), which has no deadline, so
|
||||
`chathelper.FetchContext` (`chathelper.go:107-113`) returns a bare `WithCancel` that bounds
|
||||
nothing. The only remaining ceiling is the library's shared
|
||||
`http.Client{Timeout: time.Minute}` (`bot.go:17-18,75-77`).
|
||||
|
||||
Consequence: **every** handler needs its own explicit deadline, not just the photo path.
|
||||
Ten sequential API calls under a 60s per-call ceiling is a ~10-minute bot-wide freeze.
|
||||
|
||||
### C3 — Callback data caps at 64 bytes
|
||||
|
||||
`internal/modules/stock/pending_dividend.go:16-17` enforces `maxDividendCallbackBytes = 64`
|
||||
against Telegram's limit. `/delpack` carries an opaque pending-action id, not a slug, so the
|
||||
budget is comfortable.
|
||||
|
||||
### C4 — Callback prefix conflicts are checked bidirectionally
|
||||
|
||||
`internal/modules/registry.go:216-219`. `sticker_pack:` does not overlap `stock_div:`, the
|
||||
only existing prefix.
|
||||
|
||||
### C5 — The API cannot prove ownership, so the bot must record intent before acting
|
||||
|
||||
`getStickerSet` returns only `name`, `title`, `sticker_type`, `stickers`, `thumbnail`
|
||||
(`models/sticker_set.go:4-10`) — no owner field.
|
||||
|
||||
The earlier draft concluded "therefore orphaned sets can never be adopted". Review showed
|
||||
that does not follow: the bot does not need the API to name the owner, it needs **its own
|
||||
record of who asked for that name**. Since only this bot can create `*_by_<bot_username>`
|
||||
sets, a write-ahead intent record makes an existing set attributable. See R2 and Phase 3.
|
||||
|
||||
### C6 — Raw bytes cannot ride on `AddStickerToSet`
|
||||
|
||||
`build_request_form.go:105` handles `attach://` only for `[]models.InputSticker`. The single
|
||||
`InputSticker` in `AddStickerToSetParams` (`methods_params.go:905-909`) falls through to
|
||||
`addFormFieldDefault`, and `StickerAttachment` is `json:"-"` (`models/sticker.go:40`), so it
|
||||
is silently dropped. Photo path must be `UploadStickerFile` (which accepts
|
||||
`*models.InputFileUpload`, `build_request_form.go:87`) → use the returned `File.FileID`.
|
||||
|
||||
### C7 — Library is current for stickers
|
||||
|
||||
Bot API is at 10.3 (2026-08-24); last sticker changes were Bot API 7.2 (2024-03-31).
|
||||
`v1.20.0` has `InputSticker.Format`, no top-level `sticker_format` on
|
||||
`CreateNewStickerSetParams`, and `ReplaceStickerInSet`. No known gap.
|
||||
|
||||
### C8 — Telegram limits (confirmed against official docs)
|
||||
|
||||
| Limit | Value |
|
||||
|---|---|
|
||||
| Set name | 1–64 chars, letters/digits/underscore, begins with a letter, no consecutive underscores, ends `_by_<bot_username>` (case-insensitive) |
|
||||
| Static sticker image | PNG or WEBP; one side **exactly** 512px, other ≤512px |
|
||||
| Static sticker file size | **Not documented.** The widely-repeated 512 KB figure appears in no current official page (R4) |
|
||||
| Set thumbnail | PNG/WEBP, exactly 100×100, ≤128 KB |
|
||||
| Stickers per set | 120 regular/mask, 200 custom emoji |
|
||||
| Emoji per sticker | 1–20 |
|
||||
| Set title | 1–64 chars |
|
||||
| Sets per bot | **Not documented** — no known ceiling |
|
||||
|
||||
### C9 — There is no panic barrier on the update path
|
||||
|
||||
`rg "recover()"` finds four sites: `testutil/mongotest`, `server/log_middleware.go:50`,
|
||||
`monkeyd/export_job.go:52`, `cron/scheduler.go:67`. **None on the command or callback path.**
|
||||
`internal/modules/dispatcher.go:167` carries a stale comment promising "our `recover()` in
|
||||
webhook.go"; `internal/telegram/webhook.go` contains only `DeleteWebhook`.
|
||||
|
||||
With C1, a panic in any handler terminates the process. Phase 1 closes this.
|
||||
|
||||
### C10 — `MODULES` is not opt-in
|
||||
|
||||
`internal/modules/registry.go:107-116` expands an empty list to every registered factory,
|
||||
and its own comment calls that the documented contract (`.env.example:16` ships `MODULES=`
|
||||
empty; `compose.yml:14` says "empty = all modules"). Adding a `factories()` entry **is** the
|
||||
enablement. Phase 6 sets `MODULES` explicitly before merging.
|
||||
|
||||
### C11 — `RecordingBot` cannot return structured results
|
||||
|
||||
`internal/testutil/recording_bot.go:178-195` answers every non-message-producing method with
|
||||
`{"ok":true,"result":true}`. `GetStickerSet`, `GetFile`, and `UploadStickerFile` decode into
|
||||
structs, so under the current harness they can only ever **error**. `FailMethod`
|
||||
(`:99-112`) emits no `error_code`, so library errors in tests never take the
|
||||
`ErrorBadRequest` shape production emits. Phase 1 extends the harness.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | [Phase 1: Shared prerequisites](./phase-01-shared-prerequisites.md) | Done |
|
||||
| 2 | [Phase 2: Store, set names, emoji parsing](./phase-02-store-setname-emoji.md) | Done |
|
||||
| 3 | [Phase 3: Pack lifecycle commands](./phase-03-pack-lifecycle.md) | Done |
|
||||
| 4 | [Phase 4: Sticker commands (reply path)](./phase-04-sticker-commands.md) | Done |
|
||||
| 5 | [Phase 5: Photo pipeline and pack icon](./phase-05-photo-pipeline.md) | Done |
|
||||
| 6 | [Phase 6: Wiring, menu, docs](./phase-06-wiring-docs.md) | Partial — code + docs done; live smoke and deployed MODULES pending |
|
||||
|
||||
## Dependencies
|
||||
|
||||
Phase 1 blocks everything (shared code + test harness). Phase 2 blocks 3, 4, 5. Phase 3
|
||||
blocks 4 and 5. Phase 5 depends on Phase 4's `/addsticker` handler. Phase 6 last. No
|
||||
cross-plan dependencies — the only other plan is completed and touches disjoint files.
|
||||
|
||||
## Cross-cutting rules
|
||||
|
||||
These apply to every handler in Phases 3–5. Stated once here rather than repeated.
|
||||
|
||||
1. **Explicit deadline.** Every handler opens with
|
||||
`ctx, cancel := context.WithTimeout(ctx, handlerTimeout)` (`handlerTimeout = 10s`,
|
||||
package constant). Required by C2 — nothing else bounds a call.
|
||||
2. **Durable writes survive shutdown.** Store writes that commit a completed Telegram-side
|
||||
action use `context.WithoutCancel(ctx)` plus a short timeout, mirroring the existing
|
||||
idiom at `dispatcher.go:78-84`. `rootCtx` is cancelled by SIGTERM mid-handler, so a
|
||||
plain `ctx` write fails on every deploy (R2).
|
||||
3. **One sender helper.** `senderID(msg) (int64, error)` rejects a nil `From`, `From.IsBot`,
|
||||
and any message carrying `SenderChat`. Anonymous group admins share a single
|
||||
`GroupAnonymousBot` id, so without this all anonymous admins across all groups share one
|
||||
pack namespace and one quota (R6).
|
||||
4. **Positive error classification only.** `isStickerSetMissing(err)` is
|
||||
`errors.Is(err, bot.ErrorBadRequest) && strings.Contains(err.Error(), "STICKERSET_INVALID")`.
|
||||
Any other error is "unknown — abort with no side effects". Never infer "absent" from a
|
||||
generic failure (R3, R7).
|
||||
5. **Errors from the download path never escape raw.** They are converted to a sentinel at
|
||||
the boundary, discarding the original (R5).
|
||||
|
||||
## New dependency
|
||||
|
||||
`golang.org/x/image` — for `draw.CatmullRom` resampling. Stdlib decodes JPEG/PNG and encodes
|
||||
PNG but ships no scaler, and stickers need an exact 512px long edge. Note it becomes the
|
||||
repo's first *direct* `golang.org/x/*` requirement; all current ones are indirect.
|
||||
|
||||
## Abuse surface
|
||||
|
||||
Public module creating durable Telegram-side objects on a single-threaded dispatcher (C1):
|
||||
|
||||
- One pack per user, enforced by a create-only write before `CreateNewStickerSet`. This is
|
||||
the quota; there is no separate counter to keep.
|
||||
- `handlerTimeout = 10s` on every handler — the primary bound (C2).
|
||||
- `/mypack` makes **zero** API calls; the count lives on the `Pack` record. A single `Get`,
|
||||
no `List`, no per-pack fan-out.
|
||||
- Photo source rejected above 2 MB; decoded dimensions capped at 4096×4096.
|
||||
- Slug alphabet `^[a-z][a-z0-9_]{2,39}$`, no `__`, no trailing `_`.
|
||||
- `internal/keylock` per-user serialization — genuinely load-bearing, not decorative,
|
||||
because crons and the detached stats hook run concurrently with handlers (C1 caveat).
|
||||
- Anonymous/bot senders refused outright (cross-cutting rule 3).
|
||||
|
||||
## Risks
|
||||
|
||||
| # | Risk | Signal it broke | Response |
|
||||
|---|---|---|---|
|
||||
| R1 | A handler stalls the bot for all users (C1) | Reply latency spikes for unrelated commands | `handlerTimeout` caps it at 10s; if still felt, lower it, then consider offloading the photo path (Phase 5 R1) |
|
||||
| R11 | A deleted slug may not be reclaimable | `/newpack <old-slug>` after `/delpack` reports the slug taken | Unknown at decision time; official docs are silent and community reports lean toward short names staying reserved. Does **not** block the URL-change path, which needs a *different* name. Settled empirically in Phase 6 smoke; if reserved, say so in `/delpack`'s confirm text |
|
||||
| R10 | A user wants two packs and cannot have one | Requests for a second pack | Accepted by design. Reversing it means restoring `<pack>` arguments across four commands — a deliberate, not incidental, change |
|
||||
| R2 | Partial failure strands a pack | User reports a slug reported taken that `/mypack` does not show | Write-ahead intent + `WithoutCancel` commits (Phase 3). Reverse gaps for `/delpack` and `/renamepack` documented in Phase 3 |
|
||||
| R3 | Error-code matching drifts | Users see the generic reply where a specific one was expected | Match MTProto **codes**, never human text; confirm empirically in Phase 6 smoke |
|
||||
| R4 | The 512 KB static-sticker ceiling may not be real | Uploads succeed above it, or fail below it | Client-side ceiling only; never stated as spec in user-facing text |
|
||||
| R5 | Bot token leaks through a transport error | Any log line containing `api.telegram.org/file/bot` | Sentinel conversion at the download boundary + a test asserting the error text is clean (Phase 5) |
|
||||
| R6 | Ownership collapses for anonymous senders | Two users see each other's packs | Cross-cutting rule 3 refuses them before any store access |
|
||||
| R7 | A transient error deletes a live pack's record | `/mypack` reports no pack though the user can still open theirs via link | Rule 4 — destructive store deletes require positive `STICKERSET_INVALID` |
|
||||
| R8 | Bot username changes in BotFather | Every pack refuses as "not yours" after restart | Ownership matches stored `Pack.Name` against `Sticker.SetName`; username only builds *new* names (Phase 2) |
|
||||
| R9 | `golang.org/x/image` resampling disappoints | Visibly soft or aliased stickers in smoke | Swap `CatmullRom` for `ApproxBiLinear`; one line, one file |
|
||||
| R12 | `AddStickerToSet` may reject a `file_id` from a set this bot did not create | `/addsticker` on a sticker from any other pack fails while the photo path works | The one API assumption in this plan never checked against the live API — the docs allow a `file_id` in `InputSticker.sticker` but are silent on provenance. Settle with one live call before writing Phase 4's handler. If rejected, `/addsticker`'s sticker path routes through Phase 5's `GetFile` → download → `UploadStickerFile`, which makes Phase 4 depend on Phase 5 rather than the reverse — cheap to know first, expensive to discover after |
|
||||
|
||||
## Accepted disclosure
|
||||
|
||||
`/newpack` answers "is this slug taken?" for any slug, which reveals that *some* user of this
|
||||
bot owns it. This is accepted, not solved: `t.me/addstickers/<slug>_by_<bot>` is publicly
|
||||
probeable without the bot, so the command adds no information an attacker lacks. The earlier
|
||||
draft claimed no disclosure while shipping this probe — the claim was wrong and is removed.
|
||||
Pack *management* refusals remain deliberately uniform (Phase 4), because those would
|
||||
otherwise disclose which of the caller's own guesses correspond to real packs.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] A non-admin user can reply to a sticker with `/newpack mypack My Pack` and receive a working `t.me/addstickers/mypack_by_<bot>` link
|
||||
- [ ] `/addsticker 😂` on a photo produces a valid static sticker with a 512px long edge and correct aspect ratio, with no pack named
|
||||
- [ ] Managing a pack the caller does not own fails with an ownership error and makes zero API calls
|
||||
- [ ] The not-owned reply text is byte-identical whether the set is another user's or another bot's
|
||||
- [ ] `/mypack` shows slug, title, count, and link for the caller's own pack, making no API calls
|
||||
- [ ] A second `/newpack` while a pack exists is refused, naming the existing pack and pointing at `/delpack`
|
||||
- [ ] `/delpack` takes no argument and requires inline confirmation bound to invoker, chat, message, and a TTL
|
||||
- [ ] `/delpack`'s confirm prompt states the title, sticker count, link, and permanence before the tap
|
||||
- [ ] `/renamepack`'s reply names the `/delpack` + `/newpack` route to a different URL
|
||||
- [ ] Every handler is bounded by an explicit deadline; no handler can exceed `handlerTimeout`
|
||||
- [ ] A panic in any module handler is contained and logged, and does not terminate the process
|
||||
- [ ] An interrupted `/newpack` can be completed by re-running the same command
|
||||
- [ ] Anonymous group admins and bot senders are refused before any store or API access
|
||||
- [ ] No log line or error string contains the file-download URL
|
||||
- [ ] Module is enabled only by an explicit `MODULES` entry, verified in a deployed environment
|
||||
- [ ] `go test ./...`, `go vet ./...`, `gofmt -l .`, and `golangci-lint run` all clean
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. Both prior questions were resolved by the one-pack-per-user revision: `/editsticker`
|
||||
takes no pack argument because no command but `/newpack` does (former O1), and the
|
||||
per-user pack limit is one (former O2).
|
||||
|
||||
## Design Revisions
|
||||
|
||||
### 2026-08-25 — /delpack confirmations must prove authority, not disprove it
|
||||
|
||||
Removing adoption closed the takeover class, but the same DeleteStickerSet
|
||||
primitive stayed reachable through a stale confirmation. The under-lock re-check
|
||||
was written as a blocklist — it refused only a *pending* record still naming the
|
||||
set — and fell through on the two states that mattered: no record at all, and a
|
||||
record that had moved on. Reproduced with ordinary commands and no attacker:
|
||||
prompt, pack disappears at Telegram, self-heal frees the name, another user
|
||||
claims it, first user presses, their pack is destroyed.
|
||||
|
||||
Inverted to an allowlist: delete only when a confirmed record still names this
|
||||
exact set. A guard phrased as "which states do I refuse" cannot fail closed
|
||||
against a state nobody enumerated. Dropping a pack record also clears any
|
||||
outstanding confirmation, so a dead prompt is gone rather than merely refused.
|
||||
|
||||
This also closes the reservation leak on the confirmed-delete path, since the
|
||||
moved-on case no longer reaches Telegram at all.
|
||||
|
||||
Fixed alongside: resuming an interrupted `/newpack` silently discarded a retyped
|
||||
title and reported success with the old one; a test named for freeing a dead
|
||||
name never asserted it; and `lockUser`'s comment justified the lock with cron
|
||||
and stats-hook contention that does not exist — the map is state-local and this
|
||||
module registers neither.
|
||||
|
||||
### 2026-08-25 — pack adoption removed
|
||||
|
||||
Verification found the round-4 guard defeated by the module's own recovery
|
||||
branch: an inconclusive `GetStickerSet` keeps the reservation, which turns a
|
||||
*fresh* claim into a *resumed* one, so two ordinary `/newpack` commands took
|
||||
over a stranger's pack. `resolveStaleIntent` had a second adopt path that never
|
||||
consulted the guard at all. Reproduced against the real handlers.
|
||||
|
||||
That is the fourth consecutive failure of the same mechanism, and the reason is
|
||||
structural rather than a bug that can be patched. Adoption needs to prove "this
|
||||
set is mine to finish" from local state, and local state is exactly what a
|
||||
restart on the in-memory backend erases while the packs at Telegram survive.
|
||||
Once the proof is gone the honest and the malicious case are indistinguishable.
|
||||
|
||||
Adoption is therefore removed entirely — both branches. `/newpack` refuses any
|
||||
name a set already occupies, for everyone, whatever the records say.
|
||||
|
||||
Implementing it surfaced a second hole the option did not cover: a *pending*
|
||||
record is not evidence either, and `/delpack` deletes by set name, which
|
||||
Telegram authorises for every set this bot created. Keeping a refused intent so
|
||||
the user could `/delpack` it would hand over a way to destroy the pack we had
|
||||
just refused to adopt. `/delpack` now clears a pending record locally and calls
|
||||
Telegram only for a confirmed one.
|
||||
|
||||
Accepted cost: a crash between creating a set and recording it strands that set
|
||||
permanently. Documented rather than mitigated, because every mitigation is the
|
||||
mechanism that just failed four times.
|
||||
|
||||
### 2026-08-25 — three-lens review pass (security, correctness, tests)
|
||||
|
||||
Three independent reviewers ran against the finished module. What they changed:
|
||||
|
||||
- **Adoption no longer trusts the reservation alone.** The reservation proves
|
||||
ownership only while it outlives the sets it guards, and it does not: it lives
|
||||
in our store, the packs live at Telegram, and the in-memory backend is
|
||||
selected silently whenever `MONGO_URL` is unset. After any wipe the original
|
||||
takeover was reachable again. `createOrAdopt` now refuses to adopt when *this*
|
||||
invocation first claimed the name — a genuine interrupted attempt always finds
|
||||
its own reservation waiting, so there are no false negatives.
|
||||
- **Post-action cleanup reads moved onto the detached context.** They wrote via
|
||||
`commitContext` but read on the request context, so a cancelled request failed
|
||||
the read and skipped the release while still deleting the pack record —
|
||||
stranding a name permanently. Found independently by two reviewers.
|
||||
- **Emoji clustering.** Nine valid emoji were refused outright; tag-sequence
|
||||
flags shattered; and three inputs (trailing ZWJ, a joiner before a flag, an
|
||||
odd regional-indicator count) passed validation and would have sent an
|
||||
`emoji_list` Telegram rejects.
|
||||
- **A reply tail is now reserved** from the handler budget via the existing
|
||||
`chathelper.FetchContext`, which every other data-fetching module already
|
||||
used. A slow photo `/newpack` could spend the whole 10s before Telegram was
|
||||
called, and then send its error reply on a dead context — the user saw nothing.
|
||||
- **The compression ladder resamples the scaled image, not the source.** It was
|
||||
paying a full-size resample per rung: measured 1.99s versus 74ms for the three
|
||||
rungs, on a dispatcher that runs handlers one at a time.
|
||||
- **Tests.** Mutation testing refuted the previous round's non-vacuity claim in
|
||||
three places. The `created`-flag release machinery had no coverage in either
|
||||
direction; two emoji assertions passed against a handler that never called
|
||||
Telegram; and nothing pinned module registration — the whole module could be
|
||||
removed from `factories()` with the suite still green.
|
||||
|
||||
Left open deliberately: the shutdown path never joins the polling goroutine, so
|
||||
`context.WithoutCancel` does not actually survive process exit. That is
|
||||
`cmd/server/main.go`, outside this module and affecting every module's commits.
|
||||
|
||||
### 2026-08-25 — one pack per user
|
||||
|
||||
The accepted scope originally chose "named packs, multiple per user, addressed by slug
|
||||
argument". The user revised it to one pack per user, with every command operating on that
|
||||
pack implicitly.
|
||||
|
||||
Removed by the revision:
|
||||
|
||||
- `<pack>` arguments on `/addsticker`, `/renamepack`, and `/delpack`
|
||||
- `/packlist` (replaced by `/mypack`, singular) and its `List` + per-pack `Get` fan-out
|
||||
- `maxPacksPerUser` as a tunable — the limit is one
|
||||
- The separate default-pack command and per-user prefs record that a multi-pack default
|
||||
would have required
|
||||
|
||||
Red-team findings this revision resolves outright rather than mitigates:
|
||||
|
||||
- Finding 4 (`/packlist` unbounded: 60s client timeout x 10 calls plus an N+1) — `/mypack`
|
||||
is a single `Get` and makes no API calls at all
|
||||
- Former open question O2 (`maxPacksPerUser` sizing) — no longer a choice
|
||||
|
||||
Unaffected: Phase 1 (panic barrier, test harness) and Phase 5 (photo pipeline, token-leak
|
||||
sentinel) need no changes. The write-ahead intent machinery, anonymous-sender rule,
|
||||
`handlerTimeout`, and bot-rename resilience all carry over unchanged.
|
||||
|
||||
Trade-off accepted: a user who wants a memes pack and a reactions pack separately cannot
|
||||
have both (R10).
|
||||
|
||||
### 2026-08-25 — `/delpack` as the URL-change path
|
||||
|
||||
`/delpack` was reviewed as a destructive convenience. It is in fact the **only** mechanism for
|
||||
changing a pack's URL, because Telegram exposes no rename-short-name method. That reframing
|
||||
changed three things without adding a command:
|
||||
|
||||
- `/renamepack`'s reply now names the delete-and-recreate route instead of only stating that
|
||||
the link cannot change — turning a dead end into an answer.
|
||||
- `/delpack`'s confirm prompt must state the pack title, the sticker count being destroyed, the
|
||||
exact link being surrendered, and that both are permanent. It is the last point at which a
|
||||
user learns stickers do not survive a URL change.
|
||||
- A `/repack <newslug>` migration command was considered and **rejected for this plan**: copying
|
||||
a full pack is up to ~121 sequential API calls, which blows `handlerTimeout` and stalls the
|
||||
bot for every user under C1. It is viable only after Phase 5's goroutine offload lands, and is
|
||||
recorded here as a follow-up rather than scoped in.
|
||||
|
||||
The @Stickers command set was mapped exhaustively against the one-pack model; all nine of our
|
||||
commands are either direct adaptations or (for `/mypack`) a justified addition, and every
|
||||
dropped @Stickers command has a stated reason.
|
||||
|
||||
**Bug found during this pass** (Phase 3, `/newpack`): the different-slug pending branch
|
||||
overwrote the pending record unconditionally, which permanently orphans a set that was created
|
||||
before an interruption. It now probes `GetStickerSet(oldName)` first and adopts rather than
|
||||
overwrites when the old set exists.
|
||||
|
||||
|
||||
### 2026-08-25 — post-implementation review: global slug reservation
|
||||
|
||||
An independent review of the implemented module found a **pack takeover** in
|
||||
`/newpack`, and it traces back to this plan, not only to the code.
|
||||
|
||||
Phase 3 step 5 said: `GetStickerSet` succeeds → "we hold a `Pending` record for
|
||||
this owner and slug, so this is our own interrupted attempt: adopt it". The
|
||||
plan's own risk section stated the supporting invariant as *"a `Pending` record
|
||||
for an owner means that owner asked for that name"*. That is true and **not
|
||||
sufficient**. Asking is not creating. `PutVersioned` is create-only per *owner
|
||||
key* (`packKey` is the owner ID alone); nothing reserved a name globally. So a
|
||||
user with no pack who typed another user's slug produced byte-identical evidence
|
||||
to a genuine resumed attempt — and adopted their pack, then could `/delpack` it.
|
||||
Every share link is public, so slugs are trivially enumerable.
|
||||
|
||||
The plan pre-authorised one response to this signal ("disable adoption — a
|
||||
one-line change"), which would have closed the hole by dropping the
|
||||
"an interrupted `/newpack` can be completed by re-running" success criterion.
|
||||
The user chose the stronger fix instead:
|
||||
|
||||
**A global slug reservation.** `SlugReservation{Slug, OwnerID}` is written
|
||||
create-only under `slug:<slug>` *before* Telegram is touched. Adoption is
|
||||
allowed only when the reservation names the caller. The first claimant of a name
|
||||
is the only user who can ever adopt a set under it, so "this set is mine" became
|
||||
a proven fact rather than an assumption — and both success criteria survive.
|
||||
|
||||
Consequent changes:
|
||||
|
||||
- `reserveSlug` runs before `claimSlug`; a name held by anyone else replies
|
||||
"that pack name is taken" with **zero** API calls.
|
||||
- `resolveStaleIntent` re-proves the *old* slug's reservation before adopting
|
||||
under it, and releases it on a positive "no such set".
|
||||
- `dropIntent` no longer fires on unknown errors (was a plan rule 4 violation in
|
||||
its own right): on anything but a *classified* refusal both the intent and the
|
||||
reservation survive, which is what lets a re-run recover. Only a positive
|
||||
refusal releases them.
|
||||
- `/delpack` keeps the reservation, so a deleted name stays recoverable by its
|
||||
owner and unavailable to everyone else — which also matches Telegram's likely
|
||||
behaviour for deleted short names (R11).
|
||||
|
||||
Four further defects fixed in the same pass:
|
||||
|
||||
- **A stale `/delpack` confirmation deleted a live pack's record.** Pending
|
||||
actions used a random key per invocation, so two could be live at once, and
|
||||
the delete path cleared the record *by owner* without checking it still named
|
||||
the set being deleted — precisely the documented delete-then-recreate URL
|
||||
change. Now keyed per user (matching `stock/pending_dividend.go`), superseded
|
||||
by a newer prompt, and gated on `ownsSet` before clearing.
|
||||
- **Unbounded storage from a public command.** The same random key meant a user
|
||||
who ran `/delpack` and never tapped left a permanent document.
|
||||
- **The panic barrier missed the detached hook goroutine**, so a panicking
|
||||
`CommandHook` (the `stats` module ships one) still killed the process. C9's
|
||||
"Phase 1 closes this" was true for handlers only.
|
||||
- `/delpack`'s result message bypassed `chathelper.Reply`, so in a forum
|
||||
supergroup it landed in General instead of the topic.
|
||||
|
||||
### 2026-08-25 — Phase 4 review pass
|
||||
|
||||
Ten findings applied before implementation began. Six changed what gets written:
|
||||
|
||||
- `resolveSource` takes `(ctx, b, ownerID, msg)` from the start, and `photoRef` is gone — the
|
||||
photo branch resolves to a `file_id` like every other path, so Phase 5 adds a branch instead
|
||||
of rewriting call sites.
|
||||
- The static-only gate moves onto `resolveSource`, the path that can actually receive a
|
||||
non-static sticker, and now also rejects `Type != "regular"` — a mask sticker is static yet
|
||||
invalid for a regular set, which `IsAnimated || IsVideo` does not catch.
|
||||
- `/addsticker` gained its missing no-pack path, and the note on why its refusal is
|
||||
deliberately *not* the uniform `resolveOwned` one.
|
||||
- `/delsticker` takes the same per-user keylock as `/addsticker`; both do the same
|
||||
read-modify-write on `Count`.
|
||||
- `/delsticker`'s "never deletes the record" criterion was scoped to transient and unknown
|
||||
errors. Unqualified, it forbade Phase 3's `STICKERSET_INVALID` self-heal — the very
|
||||
mechanism that unwedges a user whose set Telegram removed.
|
||||
- The animated/video check moved ahead of the store read in `resolveOwned`'s numbered gate.
|
||||
|
||||
Also: the `Count: 0` aftermath now names its recovery route, `STICKERS_TOO_MUCH` and the
|
||||
no-pack paths gained success criteria, R12 records the unverified `file_id`-reuse premise, and
|
||||
Phase 6 must document `<name...>` in the conventions doc rather than shipping an undocumented
|
||||
parameter form.
|
||||
|
||||
#### Consistency sweep — one-pack revision
|
||||
|
||||
- Files reread: plan.md and all six phase files.
|
||||
- Deltas checked: 9 (`<pack>` dropped from `/addsticker`, `/renamepack`, `/delpack`;
|
||||
`/packlist` -> `/mypack`; `listPacks` -> `getPack`; key `<ownerID>:<slug>` -> `<ownerID>`;
|
||||
`maxPacksPerUser` removed; `matchPack` -> `ownsSet`; `buildSetName` -> `makeSetName`).
|
||||
- Stale references reconciled: 4 (incl. the frontmatter description, which said
|
||||
"multi-pack-per-user" and is surfaced by every `ak plan list`). Two live risk signals in the R2/R7 rows still named
|
||||
`/packlist`; Phase 5's `/setpackicon` still said "one of the caller's packs". Phase 5 was
|
||||
therefore **not** untouched, contrary to the initial assessment.
|
||||
- Remaining `/packlist`, `listPacks`, and `maxPacksPerUser` mentions are all deliberate
|
||||
comparative or historical text ("this replaced X", the revision log, the red-team table).
|
||||
- Phases 1 and 5 confirmed free of multi-pack phrasing after the fix.
|
||||
- Unresolved contradictions: 0
|
||||
|
||||
## Red Team Review
|
||||
|
||||
### Session — 2026-08-25
|
||||
**Findings:** 28 raw from 3 reviewers → 16 after dedup (16 accepted, 0 rejected)
|
||||
**Severity breakdown:** 7 Critical, 6 High, 3 Medium
|
||||
**Reviewers:** Security Adversary (Fact Checker), Failure Mode Analyst (Flow Tracer),
|
||||
Assumption Destroyer (Scope Auditor). All findings carried `file:line` evidence, so none
|
||||
were filtered. Four of the seven Critical findings were independently corroborated by all
|
||||
three reviewers.
|
||||
|
||||
| # | Finding | Severity | Disposition | Applied To |
|
||||
|---|---------|----------|-------------|------------|
|
||||
| 1 | Empty `MODULES` loads all modules; "opt-in" false in 3 places | Critical | Accept | C10, Phase 6, Goals |
|
||||
| 2 | No `recover()` on the update path; a handler panic kills the process | Critical | Accept | C9, Phase 1 |
|
||||
| 3 | Bot token leaks via `*url.Error` into the dispatcher log | Critical | Accept | Rule 5, R5, Phase 5 |
|
||||
| 4 | `/packlist` unbounded: 60s client timeout × 10 calls + N+1 | Critical | Accept | C2, Rule 1, Phase 3 |
|
||||
| 5 | `/delsticker` probe deletes the record on any error | Critical | Accept | Rule 4, R7, Phase 4 |
|
||||
| 6 | `/delpack` button is a permanent replayable delete capability | Critical | Accept | Phase 3 |
|
||||
| 7 | `RecordingBot` cannot return structs; 3 phases untestable | Critical | Accept | C11, Phase 1 |
|
||||
| 8 | Anonymous group admins share one `From.ID` | High | Accept | Rule 3, R6 |
|
||||
| 9 | Bot rename orphans every pack; `Pack.Name` written never read | High | Accept | R8, Phase 2, Phase 4 |
|
||||
| 10 | `GetStickerSet` not-found is an undefined branch | High | Accept | Rule 4, Phase 3 |
|
||||
| 11 | Commit writes on `rootCtx`; deploy is the normal orphan path | High | Accept | Rule 2, R2, Phase 3 |
|
||||
| 12 | `/help` 4096-rune ceiling; 884 runes headroom measured | High | Accept | Phase 6 |
|
||||
| 13 | C5's no-adoption conclusion not forced by its premise | High | Accept | C5, R2, Phase 3 |
|
||||
| 14 | `Put` used where create-only `PutVersioned` exists | Medium | Accept | Phase 3 |
|
||||
| 15 | `parseSlug` case-preserving value reaches the storage key | Medium | Accept | Folded into #9 |
|
||||
| 16 | `/newpack` oracle contradicts the plan's own no-disclosure claim | Medium | Accept | Accepted disclosure section |
|
||||
|
||||
**User decisions taken during adjudication:** explicit `MODULES` before Phase 6; panic
|
||||
barrier in `modules.Install` as Phase 1; module-wide deadline plus persisted counts;
|
||||
write-ahead intent record for orphan recovery.
|
||||
|
||||
### Whole-Plan Consistency Sweep
|
||||
- Files reread: plan.md, phase-01-shared-prerequisites.md, phase-02-store-setname-emoji.md,
|
||||
phase-03-pack-lifecycle.md, phase-04-sticker-commands.md, phase-05-photo-pipeline.md,
|
||||
phase-06-wiring-docs.md
|
||||
- Decision deltas checked: 14 (phase renumber 1-5 -> 1-6; `parseSlug` -> `matchPack`;
|
||||
`/delsticker` probe removed; `/packlist` API-free via `Pack.Count`; `Pack.Pending`
|
||||
write-ahead intent; callback payload slug -> opaque id; C5 rewritten; opt-in claims
|
||||
removed; `handlerTimeout` cross-cutting rule; `senderID` rule; `isStickerSetMissing`;
|
||||
download sentinel; slug cap decoupled from C3; `Put` -> `PutVersioned` for create)
|
||||
- Reconciled stale references: 0 remaining. `parseSlug` survives only in phase-02 as the
|
||||
named superseded design and in the red-team table as finding #15 — both deliberate
|
||||
historical references, not live claims. "opt-in" survives only in C10's negative heading
|
||||
and the finding that corrected it.
|
||||
- Link integrity: all 6 phase links resolve; no orphan phase files.
|
||||
- Cross-phase references verified consistent under the new numbering.
|
||||
- Unresolved contradictions: 0
|
||||
|
||||
<!-- slug: sticker-pack-module -->
|
||||
@@ -1,196 +0,0 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Phase 1: Scope keys, normalization, matcher"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Scope keys, normalization, matcher
|
||||
|
||||
## Overview
|
||||
|
||||
The whole decision core of the module, with no Telegram and no storage in it. Three pure
|
||||
files that a test can drive directly: how a thread becomes a key prefix, how arbitrary user
|
||||
text becomes a comparable and storable form, and how a text is judged against two lists.
|
||||
|
||||
Every correctness risk in the module lives here. Isolating it means the matcher's behaviour
|
||||
is settled and tested before a single handler exists.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: a `(chatID, threadID, list)` triple maps to a unique, collision-free key
|
||||
prefix, and an entry's normalized text maps to a key under it that survives
|
||||
`internal/storage/keys.go` validation.
|
||||
- Functional: normalization composes Unicode, folds case and collapses whitespace, and
|
||||
leaves combining diacritics intact.
|
||||
- Functional: the matcher reports whether a text is blocked, which blacklist entry decided
|
||||
it, and which whitelist entry rescued it when one did.
|
||||
- Non-functional: no dependency on `go-telegram/bot` or `internal/storage` in any of the
|
||||
three files, so the package's core is unit-testable in isolation.
|
||||
- Non-functional: deterministic output for the same inputs regardless of slice order.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `internal/modules/blacklist/scope.go`
|
||||
|
||||
A thread is `(Chat.ID, MessageThreadID)`, the same pair `lol` already uses for subscriptions
|
||||
(`internal/modules/lol/subscribers.go:14-24`). `MessageThreadID == 0` means a non-forum
|
||||
group, a forum's General topic, or a DM — a real scope, not a missing value.
|
||||
|
||||
```go
|
||||
const (
|
||||
listBlack = "b"
|
||||
listWhite = "w"
|
||||
)
|
||||
|
||||
// scopePrefix is the key prefix for one list of one thread. Both numbers are
|
||||
// decimal and the list tag is a single letter, so the third ':' always ends the
|
||||
// prefix even though entry text may contain ':' of its own.
|
||||
func scopePrefix(chatID int64, threadID int, list string) string
|
||||
|
||||
// entryKey names one entry. normText must already be normalized.
|
||||
func entryKey(chatID int64, threadID int, list, normText string) string
|
||||
```
|
||||
|
||||
Key shape: `<chatID>:<threadID>:<b|w>:<encoded text>`. Group chat IDs are negative, which
|
||||
costs a leading `-` and nothing else.
|
||||
|
||||
`internal/storage/keys.go:24-39` forbids `/`, the exact strings `.` and `..`, the
|
||||
`__namespace__` pattern, and keys over 1500 bytes. The numeric prefix makes the middle three
|
||||
unreachable by construction. `/` and the length cap are handled explicitly:
|
||||
|
||||
```go
|
||||
// encodeKeyText escapes the two characters that cannot appear literally in a
|
||||
// key. '%' is escaped first so the escape marker itself stays unambiguous —
|
||||
// reversing the order would make an entry containing "%2F" decode as '/'.
|
||||
func encodeKeyText(s string) string // "%" -> "%25", then "/" -> "%2F"
|
||||
func decodeKeyText(s string) string // "%2F" -> "/", then "%25" -> "%"
|
||||
```
|
||||
|
||||
`maxEntryBytes = 200`, measured on the normalized text before encoding. Worst-case encoding
|
||||
triples it to 600 bytes, which with a prefix under 30 bytes stays far inside 1500. Two
|
||||
hundred bytes is roughly 65 Vietnamese characters — ample for a phrase, and a deliberate
|
||||
refusal to let someone paste an essay into a key.
|
||||
|
||||
### `internal/modules/blacklist/normalize.go`
|
||||
|
||||
```go
|
||||
// Normalize converts user text to the single form used for both storage keys
|
||||
// and matching. Returns ok=false when nothing comparable is left.
|
||||
func Normalize(s string) (norm string, ok bool)
|
||||
```
|
||||
|
||||
Three steps, in this order:
|
||||
|
||||
1. **NFKC** via `golang.org/x/text/unicode/norm`. Canonical composition is what makes
|
||||
Vietnamese portable: iOS clients may send `má` as `m` + `a` + combining acute (NFD) while
|
||||
Android sends the precomposed character (NFC), and without this they are different
|
||||
strings. The compatibility half additionally folds full-width and other presentation
|
||||
variants onto their plain forms, which closes an evasion route for free. It does **not**
|
||||
strip combining marks, so `má` stays `má` — the project owner's choice.
|
||||
2. **`strings.ToLower`** after normalization, not before, because folding can otherwise
|
||||
interact with composition.
|
||||
3. **Whitespace collapse** — `strings.Fields` joined by a single space, which also trims.
|
||||
`cat dog` and `cat dog` are the same rule; a newline is just whitespace.
|
||||
|
||||
Empty input, or input that is only whitespace, returns `ok=false`.
|
||||
|
||||
`golang.org/x/text` is already at v0.41.0 in `go.sum` as an indirect dependency, so this
|
||||
promotes an existing line rather than adding a dependency.
|
||||
|
||||
### `internal/modules/blacklist/match.go`
|
||||
|
||||
```go
|
||||
// Verdict is the outcome of checking one text against one thread's rules.
|
||||
type Verdict struct {
|
||||
Blocked bool
|
||||
Entry string // the blacklist entry that decided the verdict; "" when none matched
|
||||
RescuedBy string // the whitelist entry covering Entry; set only when !Blocked && Entry != ""
|
||||
}
|
||||
|
||||
// Check judges normText. Both slices hold normalized entries and are sorted by
|
||||
// the caller.
|
||||
func Check(normText string, blacklist, whitelist []string) Verdict
|
||||
```
|
||||
|
||||
The algorithm is span containment, not mere presence:
|
||||
|
||||
1. Collect every occurrence of every whitelist entry in `normText` as a `[start, end)` span,
|
||||
by looping `strings.Index` over the remainder.
|
||||
2. For each blacklist entry, in sorted order, walk its occurrences. A span is **rescued**
|
||||
only if some whitelist span `[a, b)` satisfies `a <= start && end <= b`.
|
||||
3. The first unrescued span wins: return `Blocked: true` naming its entry.
|
||||
4. If every occurrence was rescued, return `Blocked: false` naming the first rescued entry
|
||||
and the whitelist entry that covered it. If nothing matched at all, return the zero
|
||||
`Verdict`.
|
||||
|
||||
The containment rule is the point of the phase. The tempting shortcut — *allow the text if
|
||||
any whitelist entry appears anywhere in it* — passes the `assassin` case and then lets
|
||||
`I met an assassin, dumbass` through, because the whitelist span never covers the second
|
||||
match. A whitelist entry that merely overlaps a blacklist match without containing it does
|
||||
not rescue it, which is the same rule stated from the other side.
|
||||
|
||||
Sorting matters for more than tidiness: `DocStore.List` gives no ordering guarantee, so
|
||||
without it the same text could be reported against a different entry on each invocation.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/modules/blacklist/scope.go` | new |
|
||||
| `internal/modules/blacklist/normalize.go` | new |
|
||||
| `internal/modules/blacklist/match.go` | new |
|
||||
| `internal/modules/blacklist/scope_test.go` | new |
|
||||
| `internal/modules/blacklist/normalize_test.go` | new |
|
||||
| `internal/modules/blacklist/match_test.go` | new |
|
||||
| `go.mod` | `golang.org/x/text` moves from indirect to direct |
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write `scope.go` with the two key builders and the encode/decode pair.
|
||||
2. Write `normalize.go`.
|
||||
3. Write `match.go`.
|
||||
4. Write the three test files (see Validation).
|
||||
5. Run `go mod tidy`, confirming the only diff is `golang.org/x/text` changing section.
|
||||
6. `gofmt` every new file.
|
||||
|
||||
## Validation
|
||||
|
||||
`go test ./internal/modules/blacklist/...` covering at minimum:
|
||||
|
||||
**scope_test.go**
|
||||
- A negative chat ID and a zero thread ID produce a prefix ending in exactly three `:`.
|
||||
- Two different threads of the same chat produce prefixes where neither is a prefix of the
|
||||
other — the property that keeps `List` from leaking across threads.
|
||||
- `encodeKeyText` then `decodeKeyText` round-trips text containing `/`, `%`, `%2F`, `:` and
|
||||
a newline.
|
||||
- A key built from a 200-byte entry passes `storage`'s key rules.
|
||||
|
||||
**normalize_test.go**
|
||||
- The same Vietnamese word in NFC and NFD normalizes to one string.
|
||||
- `Má` and `má` normalize alike; `ma` and `má` do **not**.
|
||||
- A full-width variant folds onto its plain form.
|
||||
- `" cat dog "` normalizes to `"cat dog"`.
|
||||
- `""` and `" "` return `ok=false`.
|
||||
|
||||
**match_test.go** — blacklist `ass`, whitelist `assassin` unless stated:
|
||||
- `assassin` → not blocked, `Entry: "ass"`, `RescuedBy: "assassin"`.
|
||||
- `dumbass` → blocked.
|
||||
- `I met an assassin, dumbass` → **blocked**. The regression test for the whole phase.
|
||||
- `hello` → zero `Verdict`.
|
||||
- Empty whitelist → `assassin` is blocked.
|
||||
- An entry equal to the whole text is rescued by an identical whitelist entry.
|
||||
- Shuffling the input slices does not change which entry a verdict names.
|
||||
|
||||
## Risk
|
||||
|
||||
The containment rule is easy to implement subtly wrong in a way that passes the obvious two
|
||||
test cases. The mitigation is that the three-clause `assassin`/`dumbass` case is written
|
||||
first and named for the behaviour it protects.
|
||||
|
||||
## Rollback
|
||||
|
||||
The package has no importers until Phase 4 wires it into `cmd/server`. Delete the directory
|
||||
and revert the `go.mod` line.
|
||||
@@ -1,178 +0,0 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Phase 2: Store, factory, mutation commands"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 2: Store, factory, mutation commands
|
||||
|
||||
## Overview
|
||||
|
||||
The module becomes a real `modules.Module`: a persisted record, a factory registering six
|
||||
commands, and the four that mutate a list. The two read commands land in Phase 3, but all
|
||||
six are registered here so the command surface is reviewable in one place — the unimplemented
|
||||
pair point at stub handlers that Phase 3 fills in.
|
||||
|
||||
Blacklist and whitelist share one handler pair parameterised by list tag. They differ only
|
||||
in which prefix they write to, and duplicating them would be two bugs to fix instead of one.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: `/blacklist_add`, `/whitelist_add` store an entry in the calling thread's list,
|
||||
taking the text from the argument or, when there is none, from the replied-to message.
|
||||
- Functional: `/blacklist_del`, `/whitelist_del` remove an entry, distinguishing "removed"
|
||||
from "was not there".
|
||||
- Functional: re-adding an existing entry is reported as such rather than silently
|
||||
overwriting in silence.
|
||||
- Functional: text that normalizes to nothing, or exceeds `maxEntryBytes`, is refused with a
|
||||
message saying which.
|
||||
- Non-functional: every handler is bounded by a timeout; none can stall the single update
|
||||
worker.
|
||||
- Non-functional: user text is HTML-escaped at every render site.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `internal/modules/blacklist/blacklist.go`
|
||||
|
||||
Package doc records the two decisions a reader will otherwise re-litigate: the module is
|
||||
passive by design, and lists are per-thread and world-writable.
|
||||
|
||||
```go
|
||||
// Entry is one stored rule. The storage key holds the normalized form, so Text
|
||||
// carries what the adder actually typed — the only place the original casing and
|
||||
// spacing survive, and what /blacklist_rules shows.
|
||||
type Entry struct {
|
||||
Text string `bson:"text"`
|
||||
OwnerID int64 `bson:"ownerId"`
|
||||
CreatedAt int64 `bson:"createdAt"`
|
||||
}
|
||||
|
||||
type Store = storage.DocStore[Entry]
|
||||
|
||||
type state struct{ store Store }
|
||||
|
||||
func New(deps modules.Deps) modules.Module
|
||||
```
|
||||
|
||||
No `Registry` in `state`: nothing here needs to introspect commands the way `alias` does.
|
||||
|
||||
Both lists live in the one `Deps.Store` collection, separated by the key prefixes Phase 1
|
||||
built. One `storage.Typed[Entry]` view serves both.
|
||||
|
||||
### Thread resolution
|
||||
|
||||
```go
|
||||
// threadOf returns the scope of the message. A DM is (user's chat ID, 0) and a
|
||||
// non-forum group is (chat ID, 0); neither needs a special case.
|
||||
func threadOf(msg *models.Message) (chatID int64, threadID int)
|
||||
```
|
||||
|
||||
Replies are sent with `chathelper.Reply`/`ReplyHTML`, which already forward
|
||||
`MessageThreadID` so a reply in a forum topic stays in that topic rather than being routed
|
||||
to General.
|
||||
|
||||
### `internal/modules/blacklist/handlers.go`
|
||||
|
||||
```go
|
||||
const handlerTimeout = 10 * time.Second
|
||||
const genericFailure = "Something went wrong. Try again in a moment."
|
||||
```
|
||||
|
||||
Ten seconds matches `alias`. These handlers only touch storage, so the budget is generous.
|
||||
|
||||
```go
|
||||
// resolveText picks the entry text out of the update: the command argument when
|
||||
// there is one, otherwise the replied-to message's text. Returns the raw text
|
||||
// for echoing and the normalized form for keying.
|
||||
func resolveText(msg *models.Message) (raw, norm string, err error)
|
||||
```
|
||||
|
||||
Errors are distinguishable, because "say something useful" is the whole job here:
|
||||
no text found at all; normalizes to empty; longer than `maxEntryBytes`.
|
||||
|
||||
The reply path matters more than it looks: the natural gesture is to see a message, reply to
|
||||
it, and ban it. It also makes the length cap load-bearing, since a replied-to message can
|
||||
carry 4096 characters, and the refusal must say so rather than failing opaquely.
|
||||
|
||||
```go
|
||||
func (s *state) handleAdd(list string) modules.CommandHandler
|
||||
func (s *state) handleDel(list string) modules.CommandHandler
|
||||
```
|
||||
|
||||
Closures over the list tag, so the factory registers `s.handleAdd(listBlack)` and
|
||||
`s.handleAdd(listWhite)`.
|
||||
|
||||
`handleAdd` reads before writing to word the reply — "Added" against "Already in the
|
||||
blacklist" — accepting that a concurrent add makes the noun wrong without making the stored
|
||||
state wrong, the same trade `alias` documents at `handlers.go:120-123`.
|
||||
|
||||
`handleDel` reads before deleting for a sharper reason: `DocStore.Delete` does not
|
||||
distinguish a missing key from a removed one, so without the read "Removed" would be
|
||||
reported for something that never existed.
|
||||
|
||||
Anyone may remove anyone's entry. The list is shared by the thread, so the permission model
|
||||
is too; a per-owner rule would strand entries whose adder has left the group.
|
||||
|
||||
### Command registration
|
||||
|
||||
All `VisibilityPublic`.
|
||||
|
||||
| Name | Parameters | Description |
|
||||
|---|---|---|
|
||||
| `blacklist_add` | `[text...]` | `Add text to this thread's blacklist, or reply to a message` |
|
||||
| `blacklist_del` | `<text...>` | `Remove text from this thread's blacklist` |
|
||||
| `whitelist_add` | `[text...]` | `Add a whitelist exception, or reply to a message` |
|
||||
| `whitelist_del` | `<text...>` | `Remove a whitelist exception` |
|
||||
| `blacklist_rules` | — | `List this thread's blacklist and whitelist entries` |
|
||||
| `blacklist_check` | `<text...>` | `Check whether a text is blacklisted in this thread` |
|
||||
|
||||
`[text...]` and `<text...>` follow `docs/command-parameter-conventions.md`: square brackets
|
||||
for the optional-because-a-reply-works case, the `...` suffix for remaining text.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/modules/blacklist/blacklist.go` | new |
|
||||
| `internal/modules/blacklist/handlers.go` | new |
|
||||
| `internal/modules/blacklist/handlers_test.go` | new |
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write `blacklist.go`: `Entry`, `Store`, `state`, `New` with all six registrations (the
|
||||
two read commands pointing at stubs that reply with nothing yet).
|
||||
2. Write `threadOf` and `resolveText`.
|
||||
3. Write `handleAdd` and `handleDel` as list-parameterised closures.
|
||||
4. Write `handlers_test.go` against `storage.NewMemoryProvider()` and the test bot harness
|
||||
used by `internal/modules/alias/handlers_test.go`.
|
||||
5. `gofmt`, then run the package tests.
|
||||
|
||||
## Validation
|
||||
|
||||
`go test ./internal/modules/blacklist/...`:
|
||||
|
||||
- Adding then reading back the key confirms the entry landed under the right prefix with
|
||||
`Text` as typed, not as normalized.
|
||||
- The same text added in two different threads of one chat produces two entries, and
|
||||
removing one leaves the other.
|
||||
- `/blacklist_add` with no argument and no reply returns usage text, not a stored entry.
|
||||
- `/blacklist_add` replying to a message stores that message's text.
|
||||
- A 4096-character replied-to message is refused with the length message.
|
||||
- Text that is only whitespace or only punctuation stripped by normalization is refused.
|
||||
- Adding an existing entry replies "already", and the stored record is unchanged.
|
||||
- `/blacklist_del` for an absent entry replies "not there" and is not reported as removed.
|
||||
- An entry containing `<b>` renders escaped in every reply that echoes it.
|
||||
- `/whitelist_add` writes under the `w` prefix and is invisible to a `b`-prefix list.
|
||||
|
||||
## Risk
|
||||
|
||||
`resolveText` is where a caller's 4096-character message meets a 200-byte key. Getting the
|
||||
order wrong — keying before checking the length — turns a chatty refusal into an opaque
|
||||
storage error. Normalize, then measure, then key.
|
||||
|
||||
## Rollback
|
||||
|
||||
Still no importers outside the package. Revert the two files; Phase 1 stands alone.
|
||||
@@ -1,118 +0,0 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Phase 3: /blacklist_rules and /blacklist_check"
|
||||
status: done
|
||||
priority: P1
|
||||
effort: "2h"
|
||||
dependencies: [2]
|
||||
---
|
||||
|
||||
# Phase 3: `/blacklist_rules` and `/blacklist_check`
|
||||
|
||||
## Overview
|
||||
|
||||
The two read commands, replacing the Phase 2 stubs. `/blacklist_check` is where the Phase 1
|
||||
matcher finally meets stored data; `/blacklist_rules` is where arbitrary user text meets
|
||||
Telegram's message limit.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: `/blacklist_check` reports a verdict naming the blacklist entry that decided it
|
||||
and, when the text was rescued, the whitelist entry responsible.
|
||||
- Functional: `/blacklist_rules` shows both lists in one message, each under its own heading,
|
||||
with empty lists visibly empty rather than absent.
|
||||
- Functional: a listing too long for one Telegram message is trimmed with a count of what was
|
||||
omitted.
|
||||
- Non-functional: `/blacklist_check` costs one `List` per list and no per-entry reads.
|
||||
- Non-functional: both commands are bounded by `handlerTimeout` and escape all user text.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Loading a thread's entries
|
||||
|
||||
```go
|
||||
// entriesFor returns one list's normalized entries, sorted. The key holds the
|
||||
// normalized text, so this needs no per-entry document read — the difference
|
||||
// between one round trip and one per rule.
|
||||
func (s *state) entriesFor(ctx context.Context, chatID int64, threadID int, list string) ([]string, error)
|
||||
```
|
||||
|
||||
`DocStore.List` returns full keys, as `alias` relies on at `handlers.go:260`. Strip
|
||||
`scopePrefix(...)` with `strings.TrimPrefix`, run `decodeKeyText`, sort.
|
||||
|
||||
### `/blacklist_check`
|
||||
|
||||
Normalize the argument through `Normalize`, load both lists, call `Check`, and render the
|
||||
`Verdict`'s three shapes:
|
||||
|
||||
- `Blocked` — say so and name the entry.
|
||||
- Not blocked but `Entry != ""` — say it is allowed, name the entry that matched, and name
|
||||
the whitelist entry that rescued it. This case is the reason the whitelist is worth having
|
||||
a display for at all: without it, a user who added an exception has no way to confirm it
|
||||
is doing anything.
|
||||
- Zero `Verdict` — nothing matched.
|
||||
|
||||
Each reply also states the scope, so a user who runs the command in the wrong topic can see
|
||||
why the answer surprised them.
|
||||
|
||||
### `/blacklist_rules`
|
||||
|
||||
```go
|
||||
const maxListBytes = 3800
|
||||
```
|
||||
|
||||
The same budget `alias` uses (`internal/modules/alias/handlers.go:29-36`) and for the same
|
||||
reason: Telegram's 4096-character `sendMessage` cap measures the message actually sent, so
|
||||
the `<code>` tags count too, and at 13 bytes a pair they outweigh a short entry.
|
||||
|
||||
One message, two headed sections, blacklist first. Entries come from the stored `Entry.Text`
|
||||
rather than the key, so the list shows what people typed — which costs one read per *listed*
|
||||
entry, bounded by what fits in the message rather than by how many entries exist, exactly as
|
||||
`alias.renderNames` is. Each entry is wrapped in `<code>` so tapping it copies the text ready
|
||||
to paste into a `_del` command.
|
||||
|
||||
Both sections share the single byte budget; the trim notice names how many entries were
|
||||
omitted. An empty list prints its heading followed by a short "none yet" line, because a
|
||||
missing heading reads as a bug rather than as an empty list.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/modules/blacklist/handlers.go` | stubs replaced; `entriesFor`, `renderRules` added |
|
||||
| `internal/modules/blacklist/handlers_test.go` | extended |
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write `entriesFor`.
|
||||
2. Replace the `/blacklist_check` stub; render the three `Verdict` shapes.
|
||||
3. Replace the `/blacklist_rules` stub; write `renderRules` with the shared byte budget.
|
||||
4. Extend `handlers_test.go`.
|
||||
5. `gofmt`, run the package tests.
|
||||
|
||||
## Validation
|
||||
|
||||
`go test ./internal/modules/blacklist/...`:
|
||||
|
||||
- The plan's headline case end to end: blacklist `ass`, whitelist `assassin`, then
|
||||
`/blacklist_check assassin` is allowed and names both entries, `/blacklist_check dumbass`
|
||||
is blocked, and `/blacklist_check I met an assassin, dumbass` is **blocked**.
|
||||
- `/blacklist_check` against empty lists reports nothing matched.
|
||||
- `/blacklist_check` in thread B does not see entries added in thread A.
|
||||
- `/blacklist_rules` on empty lists prints both headings and no entries.
|
||||
- `/blacklist_rules` shows `Entry.Text` as typed, not the normalized form.
|
||||
- Enough entries to exceed `maxListBytes` produce a message under 4096 characters carrying an
|
||||
accurate omitted count.
|
||||
- An entry containing `<b>` appears escaped.
|
||||
- `entriesFor` returns sorted output given keys listed in any order.
|
||||
|
||||
## Risk
|
||||
|
||||
`renderRules` splits one byte budget across two sections; a naive implementation gives the
|
||||
first section the whole budget and leaves the whitelist permanently invisible on a busy
|
||||
thread. Reserve the trim notice before committing a line, as `alias.renderNames` does, and
|
||||
test with a blacklist alone large enough to exhaust the budget.
|
||||
|
||||
## Rollback
|
||||
|
||||
Restore the Phase 2 stubs. The mutation commands stay usable.
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "Phase 4: Wiring and documentation"
|
||||
status: done
|
||||
priority: P2
|
||||
effort: "1h"
|
||||
dependencies: [3]
|
||||
---
|
||||
|
||||
# Phase 4: Wiring and documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Register the module in the composition root and document it. This is the phase that makes
|
||||
the six commands reachable by a real user.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: the module is in `factories()`, so it loads by default and can be selected or
|
||||
omitted through `MODULES`.
|
||||
- Functional: `/help` and Telegram's native command menu list all six commands, which follows
|
||||
automatically from registration and needs only to be asserted.
|
||||
- Non-functional: README and a feature doc describe the behaviour a user can observe,
|
||||
including the two things most likely to surprise them.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `cmd/server/main.go`
|
||||
|
||||
One line in `factories()` (`cmd/server/main.go:80-99`):
|
||||
|
||||
```go
|
||||
"blacklist": blacklist.New,
|
||||
```
|
||||
|
||||
Plain string rather than a `CollectionName` constant, matching `alias`, `gold` and `misc`;
|
||||
only modules whose name is referenced elsewhere export one.
|
||||
|
||||
An unset or empty `MODULES` loads every registered module (`internal/modules/registry.go:178`),
|
||||
so this line alone enables it on the existing deployment. No `init*Store` call is needed —
|
||||
the module has no startup migration and no cross-module state.
|
||||
|
||||
### `docs/blacklist.md`
|
||||
|
||||
Following `docs/aliases.md` in shape. It has to answer the three questions a user will
|
||||
actually arrive with:
|
||||
|
||||
- **The bot does not police chat.** The lists are inert until `/blacklist_check` asks. Worth
|
||||
stating first and plainly, because the module's name promises enforcement it deliberately
|
||||
does not perform.
|
||||
- **Lists are per topic.** Entries added in one forum topic are invisible in the next, and a
|
||||
DM has its own private list. This is the most likely support question.
|
||||
- **Diacritics are significant.** `ma`, `má` and `mà` are three entries; catching all three
|
||||
means adding all three. Case and spacing are not significant, and the same word typed on
|
||||
an iPhone and on Android match.
|
||||
|
||||
Plus a worked whitelist example, since span containment is not guessable: blacklist `ass`,
|
||||
whitelist `assassin`, and the three outcomes including `I met an assassin, dumbass`.
|
||||
|
||||
### `README.md`
|
||||
|
||||
One row in the module table:
|
||||
|
||||
```text
|
||||
| `blacklist` | Per-topic text deny-list with whitelist exceptions: `/blacklist_add`, `/blacklist_del`, `/whitelist_add`, `/whitelist_del`, `/blacklist_rules` lists both, `/blacklist_check` judges a text. Passive — the bot never scans chat. See [docs/blacklist.md](docs/blacklist.md) |
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `cmd/server/main.go` | import + one `factories()` entry |
|
||||
| `cmd/server/main_test.go` | extended |
|
||||
| `docs/blacklist.md` | new |
|
||||
| `README.md` | one table row |
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the import and the `factories()` entry.
|
||||
2. Extend `TestFactoriesIncludesExpectedModules` (`cmd/server/main_test.go:111`) to build a
|
||||
registry containing `blacklist` and assert all six command names resolve.
|
||||
3. Write `docs/blacklist.md`.
|
||||
4. Add the README row.
|
||||
5. `gofmt`, then the full gate below.
|
||||
|
||||
## Validation
|
||||
|
||||
- `go test ./...`
|
||||
- `go vet ./...`
|
||||
- `golangci-lint run`
|
||||
- `MODULES=blacklist` builds a registry with exactly the six commands and no conflict.
|
||||
- Unset `MODULES` builds a registry including `blacklist` alongside every existing module —
|
||||
the check that no command name collides with an existing one.
|
||||
- Every link in `README.md` and `docs/blacklist.md` resolves.
|
||||
|
||||
## Risk
|
||||
|
||||
A command-name collision surfaces only when the full catalog is built, because
|
||||
`Registry.addCommands` rejects duplicates across modules at startup. The unset-`MODULES`
|
||||
test is what turns that from a deploy-time crash into a test failure. `blacklist_*` and
|
||||
`whitelist_*` are unused by any existing module today, so the expected result is green.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the `factories()` entry; the package becomes dead code without affecting a running
|
||||
deployment. Revert the docs separately.
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
title: "Blacklist module"
|
||||
description: "internal/modules/blacklist — per-thread deny-list and exception-list of text, curated by anyone, queried on demand. Passive: the bot never acts on chat traffic."
|
||||
status: done
|
||||
priority: P2
|
||||
effort: ""
|
||||
tags: ["blacklist", "telegram-bot", "module"]
|
||||
created: 2026-09-15
|
||||
branch: main
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# Blacklist module
|
||||
|
||||
## Overview
|
||||
|
||||
New module `internal/modules/blacklist`. Each chat thread curates two lists of text: a
|
||||
**blacklist** of forbidden entries and a **whitelist** of exceptions that rescue false
|
||||
positives. `/blacklist_check <text>` scans a text against that thread's rules and reports a
|
||||
verdict.
|
||||
|
||||
The module is **passive**. It never reads ordinary chat messages and never deletes, warns,
|
||||
or restricts anyone. It only answers when a command is invoked.
|
||||
|
||||
## Accepted scope
|
||||
|
||||
| Decision | Value |
|
||||
|---|---|
|
||||
| Behaviour | Passive registry. No message scanning, no enforcement |
|
||||
| Scope | Per thread — `(Chat.ID, MessageThreadID)`; a DM is `(userID, 0)` |
|
||||
| Visibility | All `VisibilityPublic`; anyone in a thread may modify that thread's lists |
|
||||
| Matching | Substring, in normalized space |
|
||||
| Normalization | NFKC + lowercase + whitespace collapse. **Diacritics preserved** |
|
||||
| Whitelist | Exception layer — rescues a blacklist match only by span containment |
|
||||
|
||||
### Why passive
|
||||
|
||||
Enforcement would need a `Module.MessageHook` primitive the dispatcher does not have
|
||||
(`internal/modules/dispatcher.go` registers only commands, callback prefixes, one fallback
|
||||
and one inline query), plus privacy mode disabled in BotFather and group-admin delete
|
||||
rights. All of that is out of scope. Nothing in this plan forecloses adding it later: the
|
||||
matcher is a pure function a future hook can call unchanged.
|
||||
|
||||
### Why diacritics are preserved
|
||||
|
||||
Chosen by the project owner. `ma`, `má` and `mà` are three distinct entries. The cost is
|
||||
that dropping a diacritic evades an entry, so each form worth catching must be added
|
||||
separately — a curation choice, not a defect. NFKC still runs, so full-width and
|
||||
compatibility variants of the same characters do collapse, and Vietnamese text composed as
|
||||
NFD by iOS clients matches the same text composed as NFC by Android clients.
|
||||
|
||||
## Command surface
|
||||
|
||||
All `VisibilityPublic`. `Parameters` follows `docs/command-parameter-conventions.md`.
|
||||
|
||||
| Command | Parameters | Behaviour |
|
||||
|---|---|---|
|
||||
| `/blacklist_add` | `[text...]` | Add to this thread's blacklist. No argument → the replied-to message's text |
|
||||
| `/blacklist_del` | `<text...>` | Remove from this thread's blacklist |
|
||||
| `/whitelist_add` | `[text...]` | Add an exception. No argument → the replied-to message's text |
|
||||
| `/whitelist_del` | `<text...>` | Remove an exception |
|
||||
| `/blacklist_rules` | — | This thread's blacklist and whitelist entries, in one message |
|
||||
| `/blacklist_check` | `<text...>` | Verdict for the text, naming the entry that matched and any exception that rescued it |
|
||||
|
||||
`/blacklist_rules` carries the `blacklist_` prefix so Telegram's native menu sorts it beside
|
||||
the other `blacklist_*` commands, and its description states that it shows both lists.
|
||||
|
||||
## Goals
|
||||
|
||||
| # | Goal | Priority |
|
||||
|---|------|----------|
|
||||
| 1 | Two threads of one forum keep entirely separate lists | P1 |
|
||||
| 2 | A whitelist entry rescues only the blacklist matches it actually spans | P1 |
|
||||
| 3 | Vietnamese text matches regardless of the client's Unicode composition | P1 |
|
||||
| 4 | No command can stall the bot beyond a bounded deadline | P1 |
|
||||
| 5 | Arbitrary user text is safe as a storage key and as Telegram HTML | P1 |
|
||||
| 6 | A list too long for one Telegram message is trimmed, not dropped | P2 |
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Depends on | Effort |
|
||||
|---|---|---|---|
|
||||
| 1 | [Scope keys, normalization, matcher](phase-01-scope-normalize-match.md) | — | done |
|
||||
| 2 | [Store, factory, mutation commands](phase-02-store-and-mutations.md) | 1 | done |
|
||||
| 3 | [`/blacklist_rules` and `/blacklist_check`](phase-03-rules-and-check.md) | 2 | done |
|
||||
| 4 | [Wiring and documentation](phase-04-wiring-and-docs.md) | 3 | done |
|
||||
|
||||
Phases 2 and 3 landed in one edit: both write `internal/modules/blacklist/handlers.go`,
|
||||
so the planned stub-then-replace step was skipped.
|
||||
|
||||
Phase 1 is pure Go with no Telegram and no storage, so it carries the whole matcher
|
||||
test suite and can be reviewed on its own.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. Adding, listing, checking and deleting in two threads of the same forum supergroup do not
|
||||
leak across threads; the same commands work in a DM.
|
||||
2. Given blacklist `ass` and whitelist `assassin`: `assassin` is ALLOWED, `dumbass` is
|
||||
BLACKLISTED, and `I met an assassin, dumbass` is BLACKLISTED.
|
||||
3. `má` does not match an entry `ma`; `Má` does match an entry `má`; the same Vietnamese
|
||||
word in NFC and NFD form match each other.
|
||||
4. An entry containing `/`, `%`, `:` or a newline round-trips through storage and renders
|
||||
escaped in `/blacklist_rules`.
|
||||
5. `/blacklist_rules` with more entries than fit in 4096 characters returns a trimmed
|
||||
message with a count of what was omitted.
|
||||
6. `go test ./...`, `go vet ./...` and `golangci-lint run` all pass.
|
||||
|
||||
## Stats compatibility
|
||||
|
||||
Six new command names, no rename and no deletion, so `AGENTS.md`'s stats migration rules
|
||||
impose no work. Command names must not change after release without the migration those
|
||||
rules require.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| User text used as a storage key hits the `/`-forbidden and 1500-byte rules in `internal/storage/keys.go` | Percent-encode `%` then `/`; cap normalized entries at 200 bytes (Phase 1) |
|
||||
| `DocStore.List` has no ordering guarantee, so verdicts could name different entries across runs | Sort entries before scanning (Phase 1) |
|
||||
| Unescaped user text in an HTML reply | Every entry passes through `html.EscapeString` at every render site (Phases 2-3) |
|
||||
| `golang.org/x/text` promoted from indirect to direct dependency | Already present at v0.41.0 in `go.sum`; no new download, `go mod tidy` only moves the line |
|
||||
|
||||
## Outcome
|
||||
|
||||
All four phases delivered; every acceptance criterion above verified. Full gate green:
|
||||
`go test ./...`, `go vet ./...`, `golangci-lint run` (0 issues).
|
||||
|
||||
Two files the plan did not anticipate had to change, both required by existing repo
|
||||
contracts rather than by the feature:
|
||||
|
||||
- `cmd/server/command_menu_test.go` pins every public command's `Parameters` string, and
|
||||
its reverse loop makes even an empty entry load-bearing. The six new commands are
|
||||
registered there.
|
||||
- The six command `Description` strings were shortened after review. `/help` renders as one
|
||||
un-chunked message pinned at 4096 runes, and the first draft left 9 runes of headroom;
|
||||
the shorter wording restores it to 76. See the open questions below — that ceiling is a
|
||||
shared limit this module did not create and cannot fix alone.
|
||||
|
||||
`/blacklist_rules` reads each list with `DocStore.Scan`, which landed on `main` while this
|
||||
work was in progress. The phase-3 design called for `List` followed by a `Get` per displayed
|
||||
entry, copying `alias.renderNames`; `Scan` exists precisely to remove that N+1, and this
|
||||
command paid it twice per invocation. `/blacklist_check` still uses `List`, because it needs
|
||||
only the entry names and never their stored text.
|
||||
|
||||
Test integrity was checked by mutation: fourteen mutants were injected against the matcher,
|
||||
key encoding, normalization, trimming and escaping. The four that initially survived —
|
||||
three HTML-escaping sites and the post-normalization byte cap — are now covered by tests
|
||||
verified to fail without the guard they pin.
|
||||
|
||||
## Post-review decisions
|
||||
|
||||
1. **Reply-thread scoping.** `threadOf` now gates on `msg.IsTopicMessage` rather than
|
||||
trusting `msg.MessageThreadID` alone. Telegram associates a thread id with any reply chain
|
||||
in a supergroup, not only with a forum topic, so the original code would have given a
|
||||
reply-form `/blacklist_add` in a plain supergroup a scope that a later standalone
|
||||
`/blacklist_rules` could never read back. A forum topic keeps its own lists; a plain
|
||||
group, a DM, and a forum's General topic all resolve to thread 0. Pinned by
|
||||
`TestReplyChainThreadIsNotATopic`, verified to fail without the gate.
|
||||
2. **Per-thread entry count stays unbounded**, matching `alias`, which is equally
|
||||
world-writable and equally uncapped. Entries remain capped at 200 bytes each. Revisit only
|
||||
if a thread's list grows large enough to make `/blacklist_check` slow.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Plan: `thoitiet` weather module
|
||||
|
||||
Status: done (2026-10-01). Design and decisions:
|
||||
[research report](../reports/research-261001-0900-thoitiet-weather-module.md).
|
||||
|
||||
## Outcome
|
||||
|
||||
Public commands `/thoitiethomnay` (alias `/thoitiet`), `/thoitietngaymai`, and
|
||||
`/thoitiettuannay`, each taking `[location...]` and defaulting to Ho Chi Minh
|
||||
City, backed by Open-Meteo geocoding and forecast APIs.
|
||||
|
||||
## Steps
|
||||
|
||||
1. `internal/modules/thoitiet`: `thoitiet.go` (registration + handlers),
|
||||
`api_client.go` (geocode + forecast), `location.go` (diacritic stripping,
|
||||
aliases, result choice), `format.go` (WMO labels, three renderers).
|
||||
2. Tests for location parsing, formatting, and handlers against `httptest`.
|
||||
3. Wire `"thoitiet": thoitiet.New` in `cmd/server/main.go`; pin the new
|
||||
parameter strings in `cmd/server/command_menu_test.go`.
|
||||
4. README module table row.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- No argument shows Ho Chi Minh City without a geocoding request.
|
||||
- `Đà Lạt`, `Da Lat`, `hcm`, `saigon`, and foreign names like `Tokyo` resolve.
|
||||
- `/thoitiettuannay` lists 7 days starting today in the location's timezone.
|
||||
- Unknown location and upstream failure reply with Vietnamese error text.
|
||||
- `go test ./...`, `go vet ./...`, and `golangci-lint run` pass.
|
||||
@@ -1,41 +0,0 @@
|
||||
# /gacha wish command
|
||||
|
||||
Status: done
|
||||
|
||||
## Outcome
|
||||
|
||||
`/gacha <option[*rarity],...>` picks one option and replies with a Genshin
|
||||
Impact style wish animation (meteor coloured by rarity, white flash, reveal
|
||||
with stars), rendered as a silent MP4 by the existing wheelofnames service.
|
||||
|
||||
## Decisions (user-approved 2026-10-01)
|
||||
|
||||
- Renderer lives in `tiennm99/wheelofnames` as `POST /api/gacha`; no new repo
|
||||
or deployment.
|
||||
- Rarity is a user prefix: `Pizza, 4* Pho, 3* Rice` (unprefixed = 5★;
|
||||
switched from the original `Pizza*5` suffix with a 3★ default on 2026-10-01).
|
||||
- Single pull only.
|
||||
- Output MP4 (H.264, no audio) sent via `sendAnimation`.
|
||||
|
||||
## Design
|
||||
|
||||
- Every option is equally likely, as with /random; the rarity tag is
|
||||
cosmetic. (First shipped with Genshin tier rates; the user switched to
|
||||
uniform odds on 2026-10-01.)
|
||||
- All visuals are procedural (CSS/gradients); no HoYoverse assets.
|
||||
- Bot derives the gacha endpoint from `WHEELOFNAMES_API_URL` (sibling path
|
||||
`gacha` next to `gif`), so no new env var. Same bearer token.
|
||||
- Bot is the source of truth for winner and rarity; renderer only draws.
|
||||
- Fallback without renderer or on failure: text reply `★★★★★ Pizza`.
|
||||
|
||||
## Phases
|
||||
|
||||
1. Renderer: schema, `GachaComposition`, MP4 render, route, tests, docs.
|
||||
2. Bot: parser, weighted pick, API client, command, tests, README/docs.
|
||||
3. Verify: renderer stills via Docker, lint/typecheck/test, go test/vet/lint.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Each rarity renders with the correct meteor/glow colour and star count.
|
||||
- `/gacha` with no options shows usage; every option is equally likely, like /random.
|
||||
- Renderer failure falls back to text; thread IDs forwarded.
|
||||
@@ -1,50 +0,0 @@
|
||||
# Plan: `/thuyvan` flood alerts for Tân Thuận
|
||||
|
||||
Status: implemented, not yet committed (2026-10-02)
|
||||
Design: [research report](../reports/research-261002-1205-thuyvan-flood-alert.md)
|
||||
|
||||
## Outcome
|
||||
|
||||
`/thuyvan` (no parameter) shows Tân Thuận's flood risk: a 5-day tide-peak
|
||||
forecast at Phú An and Nhà Bè from the KTTV Nam Bộ bulletin, the rain forecast
|
||||
from Open-Meteo, and live VNDMS levels at stations within 30 km. Chats that opt
|
||||
in with `/thuyvan_subscribe` get a 10:30 ICT push, but only when some day has a
|
||||
tide peak of at least 1.40 m (BĐ I) or at least 50 mm of rain.
|
||||
|
||||
## Constraints and non-goals
|
||||
|
||||
- The `thoitiet` module is renamed to `weather`. The command names stay the
|
||||
same.
|
||||
- Subscriber storage and push fan-out move out of `lol` into a shared helper,
|
||||
and `lol` behaviour stays the same.
|
||||
- Non-goals: other locations, tide modelling, or an evening push.
|
||||
|
||||
## Phases
|
||||
|
||||
1. [x] Rename `internal/modules/thoitiet` to `internal/modules/weather`
|
||||
(package, catalog key, README).
|
||||
2. [x] Extract `internal/modules/util/subscription`: the subscriber list, the
|
||||
terminal-error classifier, the per-day claim and fan-out with pruning.
|
||||
Move `lol` onto it.
|
||||
3. [x] Tide bulletin: homepage → article → PDF → peak rows, with a strict
|
||||
validator and a real PDF fixture.
|
||||
4. [x] VNDMS: parse the popups, tag alarm tiers, keep stations within 30 km of
|
||||
Tân Thuận, using a trimmed fixture.
|
||||
5. [x] `/thuyvan`, the subscribe and unsubscribe commands, and the 10:30 ICT
|
||||
cron. Wire up the menu test and the README.
|
||||
6. [x] Run `go test ./...`, `go vet ./...` and `golangci-lint run`, then
|
||||
review.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `/thuyvan` renders today's bulletin (fixture) with the BĐ tiers. When a
|
||||
source fails, its section is replaced by a note.
|
||||
- The push is sent only on risk days, once per ICT day, and prunes dead chats.
|
||||
- The `lol` tests pass unchanged in behaviour.
|
||||
|
||||
## Risk
|
||||
|
||||
- The bulletin PDF layout could drift. The strict row validator turns that
|
||||
into a missing-bulletin note.
|
||||
- `MODULES=thoitiet` fails startup after the rename. Check Coolify before
|
||||
pushing.
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
status: completed
|
||||
mode: port
|
||||
---
|
||||
|
||||
# Port monkeyd-crawler into the monkeyd module
|
||||
|
||||
## Outcome
|
||||
|
||||
The bot builds without the `third_party/monkeyd-crawler` submodule. The crawler,
|
||||
PDF renderer, and export flow live in-tree under `internal/modules/monkeyd/`,
|
||||
trimmed to what the bot uses. Coolify deploys succeed again.
|
||||
|
||||
Why now: `tiennm99/monkeyd-crawler` was merged into `tiennm99/mttools` and the
|
||||
old repo is gone, so every recursive clone fails (Coolify deployment
|
||||
`6klach0etmr5ymnfrvbkq77a`, "Repository not found").
|
||||
|
||||
## Source manifest
|
||||
|
||||
- Source: `tiennm99/mttools`, path `monkeyd-crawler/`, commit `03b1400`
|
||||
- It matches the pinned submodule commit `d88f2a4` except for import paths
|
||||
- License: Apache-2.0, same author as this repo (also Apache-2.0); bundled
|
||||
DejaVu Sans keeps its `NOTICE.md`
|
||||
|
||||
## Source anatomy
|
||||
|
||||
| Package | Role | Bot uses |
|
||||
|---|---|---|
|
||||
| `monkeyd` | HTTP client (rate limit, retries, size cap), disk cache, novel and chapter parsing, CSS word-class decoding, worker pool | `Crawler.NovelInfo` and `NewClient` for `/monkeyd_tags`, plus the export path |
|
||||
| `pdfout` | fpdf layout, page presets, font discovery plus bundled DejaVu | the export path, plus `Presets` in a test |
|
||||
| `export` | orchestration: defaults, validation, crawl, render | `Export`, `Request`, `Result`, `DefaultFontSize`, `DefaultDelay` |
|
||||
| `cmd/monkeyd-crawler` | CLI | nothing |
|
||||
|
||||
## Dependency matrix
|
||||
|
||||
| Source | Local | Status |
|
||||
|---|---|---|
|
||||
| `monkeyd` package | `internal/modules/monkeyd/crawler` | NEW (port as-is) |
|
||||
| `pdfout` package | `internal/modules/monkeyd/pdf` | NEW (drop system font discovery) |
|
||||
| `export` package | `internal/modules/monkeyd/export` | NEW (trim the request surface) |
|
||||
| CLI | none | DROPPED |
|
||||
| `go-pdf/fpdf`, `x/net`, `x/sync`, `x/image` | `go.mod` | EXISTS (indirect deps become direct) |
|
||||
| submodule, `replace`, Dockerfile COPY, CI `submodules: true` | none | REMOVED |
|
||||
|
||||
## Decision matrix
|
||||
|
||||
| Decision | Source's way | Our way | Recommendation |
|
||||
|---|---|---|---|
|
||||
| Distribution | Go module in mttools | in-tree packages | In-tree, as requested. No cross-repo pin to drift, and Coolify needs no recursive clone |
|
||||
| Font | system font discovery, then bundled | bundled only | Bundled only. Production has no system fonts, so its output is unchanged, and dev machines render the same PDF |
|
||||
| Page presets | phone, a5, a4 | phone only | Fixed phone page; the bot exposes no page option |
|
||||
| Export knobs | page, font file, spacing, margin, workers, retries, delay, limit, out path, no-cache, no-delay | URL, out dir, font size, cache dir, log | Keep only what the bot sets. The rest become constants with the source's defaults |
|
||||
| CLI | `cmd/monkeyd-crawler` | none | Drop; mttools keeps the CLI |
|
||||
|
||||
Risk score: 3 of 10. The logic is Go to Go and identical to what runs today.
|
||||
The main risk is a trim that drops behavior, and the ported tests cover that.
|
||||
|
||||
## Phases
|
||||
|
||||
1. Port the packages. Copy `monkeyd`, `pdfout`, and `export` with their tests
|
||||
into `internal/modules/monkeyd/{crawler,pdf,export}`. Rewrite imports and
|
||||
apply the trims above. Remove tests that only cover dropped features.
|
||||
2. Rewire the module. Point `export_job.go`, `monkeyd.go`, `tags_command.go`,
|
||||
and `handlers_test.go` at the new packages.
|
||||
3. Remove the submodule. Delete `third_party/monkeyd-crawler`, `.gitmodules`,
|
||||
and the `replace`/`require` lines; run `go mod tidy`. Drop the Dockerfile
|
||||
COPY and CI `submodules: true`.
|
||||
4. Update docs: README (layout, run locally, clone instructions), AGENTS.md,
|
||||
and `docs/deploy-coolify-selfhosted.md` (recursive clone note).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `go build ./...`, `go vet ./...`, `go test ./...`, and `golangci-lint run`
|
||||
pass with no submodule present
|
||||
- `docker compose build` passes
|
||||
- A live export of a real novel URL produces a PDF with Vietnamese diacritics
|
||||
- The Coolify deployment of the pushed commit finishes
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the commits. The old submodule URL no longer resolves, so a rollback
|
||||
needs `.gitmodules` pointed at `tiennm99/mttools` with the path adjusted, or a
|
||||
module dependency on `github.com/tiennm99/mttools/monkeyd-crawler`.
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Code rebrand"
|
||||
status: done
|
||||
owner: agent
|
||||
---
|
||||
|
||||
# Phase 1 — Code rebrand
|
||||
|
||||
## Context
|
||||
|
||||
Every in-repo `miti99bot` occurrence, listed in the
|
||||
[scout report](../reports/scout-261007-1423-rebrand-tiennm99bot.md). The bot's
|
||||
own username is already runtime-resolved (`BOT_USERNAME` or getMe), so nothing
|
||||
here depends on the new Telegram account.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Go module path.** In `go.mod`, change it to `github.com/tiennm99/tiennm99bot`,
|
||||
then rewrite every import:
|
||||
`git ls-files '*.go' | xargs sed -i 's#github.com/tiennm99/miti99bot#github.com/tiennm99/tiennm99bot#g'`.
|
||||
`util/help.go` `repoURL` and `util/help_test.go` follow from the same sed.
|
||||
2. **Runtime strings** → `tiennm99bot`:
|
||||
- `internal/server/health.go` health body, plus the `compose.yml` and
|
||||
`docs/deploy-coolify-selfhosted.md` text that quotes it.
|
||||
- `internal/deploynotify/deploy_notify.go` DM text, plus its test.
|
||||
- User-Agents in `coin/price_providers.go`, `gold/vnappmob_client.go` (×2),
|
||||
`stock/prices_ssi.go`, `lol/api_client.go` (`userAgentProduct`), plus
|
||||
`stock/prices_test.go` and `lol/api_client_test.go`.
|
||||
- `monkeyd/export_job.go` `cacheDirName`.
|
||||
- `renderer/src/gacha/page/page.js` edition label.
|
||||
3. **Sticker slug.** Set `sticker/sticker_pack.go` `defaultStickerPackSlug` to the
|
||||
confirmed slug (proposed `stickers`). Update the comment example and the tests
|
||||
(`addsticker_command_test.go`, `sticker_pack_test.go`), plus the examples in
|
||||
`docs/sticker-packs.md`, `.env.example` and `compose.yml`.
|
||||
4. **Test fixtures.** Change `/cmd@miti99bot` to `@tiennm99bot` in
|
||||
`modules/dispatcher_test.go` and `alias/fallback_test.go`, and the comment in
|
||||
`stats/views.go`. Change the test DB prefixes `miti99bot_*` to
|
||||
`tiennm99bot_*` in four `*_mongo*_test.go` files. Update the
|
||||
`sticker_pack_test.go` username fixture. Leave `misc/handlers_test.go`
|
||||
`@miti99` alone, because it is a user handle.
|
||||
5. **loldle stickers.** Update the `loldle/stickers.go` comment to name
|
||||
@tiennm99bot. The file_ids are replaced in phase 2, because they need the
|
||||
new bot.
|
||||
6. **Build/deploy config.**
|
||||
- `.github/workflows/ci.yml` image tags `tiennm99bot`, `tiennm99bot-renderer`.
|
||||
- `compose.yml`: the commented GHCR image and the DB example.
|
||||
- `.env.example`: the header and `MONGO_DATABASE=tiennm99bot`.
|
||||
- `renderer/package.json` name `tiennm99bot-renderer`. Regenerate the lock
|
||||
file with `npm install --package-lock-only` in `renderer/`; do not edit it
|
||||
by hand.
|
||||
7. **Docs.**
|
||||
- `README.md` (title, local mongo container name, `_dev` DB), `AGENTS.md`,
|
||||
`CLAUDE.md`, `docs/deploy-coolify-selfhosted.md`.
|
||||
- `renderer/README.md`, `renderer/docs/deployment.md`.
|
||||
- `git mv renderer/docs/miti99bot-integration.md renderer/docs/tiennm99bot-integration.md`,
|
||||
then fix its links (`git grep -n miti99bot-integration`).
|
||||
8. **Sweep.** `git grep -n -i miti99bot -- ':!plans'` must be empty. Review any
|
||||
remaining `miti99` hits by hand.
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
gofmt -l . && go vet ./... && go test -race -count=1 ./... && go build ./...
|
||||
(cd renderer && npm ci && npm run lint && npm run typecheck && npm test)
|
||||
docker build -t tiennm99bot . && docker build -t tiennm99bot-renderer renderer
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
Use two focused conventional commits on `main`:
|
||||
- `feat(config): resolve the bot username from BOT_USERNAME or getMe` (the existing uncommitted change)
|
||||
- `refactor!: rebrand miti99bot to tiennm99bot`
|
||||
|
||||
The body notes that the default sticker pack and the module path changed.
|
||||
Delay the push to the cutover in phase 2 if the old bot should keep its old strings.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- Go module path change: purely mechanical, and the build and tests catch any miss.
|
||||
- Pushing deploys to Coolify. If it is pushed before cutover, the only visible
|
||||
effects are new strings and a new default pack name on the *old* bot, where
|
||||
`/addsticker` would create `stickers_by_miti99bot`. To avoid that, set
|
||||
`STICKER_PACK_NAME=miti99_by_miti99bot` in Coolify until cutover, or hold the push.
|
||||
- Rollback: `git revert` the rebrand commit.
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Cutover and manual steps"
|
||||
status: in-progress
|
||||
owner: user + agent
|
||||
blockedBy: [phase-01]
|
||||
---
|
||||
|
||||
# Phase 2 — Cutover and manual steps
|
||||
|
||||
Legend: **[you]** only you can do it (BotFather, Atlas UI, Coolify secrets).
|
||||
**[agent]** I can run it once you approve that step.
|
||||
|
||||
Verified facts (2026-10-07):
|
||||
- The Coolify app `miti99bot` is on **miti-sg** (uuid `ofo63lqv73huw1hce24ntg9i`).
|
||||
- It has no `STICKER_PACK_NAME` and no `BOT_USERNAME` set.
|
||||
- The name `tiennm99/tiennm99bot` is free on GitHub.
|
||||
- `mongodump`/`mongorestore` are not installed here, but the local `mongo:8`
|
||||
image ships them.
|
||||
|
||||
## A. Prepare (no downtime)
|
||||
|
||||
1. **[you] Create the bot.** In BotFather, run `/newbot` → username `tiennm99bot`
|
||||
and keep the token. Then:
|
||||
- `/setinline` on @tiennm99bot. The alias inline picker needs it
|
||||
([docs/aliases.md](../../docs/aliases.md)).
|
||||
- Optionally set `/setdescription`, `/setabouttext`, `/setuserpic`, and
|
||||
`/setjoingroups` (keep enabled).
|
||||
- The command menu needs nothing here: the bot registers it at startup.
|
||||
2. **[you] Start the new bot.** Open @tiennm99bot from the `OWNER_ID` account
|
||||
and press Start. Without that, the deploy DM fails with 403. Admins should
|
||||
do the same.
|
||||
3. **[you] Create the Atlas DB user.** Make a user with `readWrite` on database
|
||||
`tiennm99bot` only, and build the new `MONGO_URL` with it. Keep the old user
|
||||
until step D.
|
||||
4. ✅ **Done 2026-10-07.** **[agent] Rename the repo.** Run `gh repo rename tiennm99bot -R tiennm99/miti99bot`.
|
||||
GitHub keeps a redirect from the old URL. Then set the local `origin` to
|
||||
`https://github.com/tiennm99/tiennm99bot.git`.
|
||||
5. ✅ **Done 2026-10-07.** **[agent] Move the checkout.** Move `/workspace/tiennm99/miti99bot` to
|
||||
`/workspace/tiennm99/tiennm99bot`. `/tiennm99/` is already ignored at the
|
||||
workspace root. Restart the Claude session from the new path, because
|
||||
project memory is keyed by path.
|
||||
|
||||
## B. Cutover window (bot offline about 10–15 min)
|
||||
|
||||
1. **[agent] Stop the app.** Use `control stop` on the Coolify app (confirm
|
||||
required) so nothing writes during the copy.
|
||||
2. **[agent] Back up and copy the database**, using the `mongo:8` image, with
|
||||
the URL passed via env and never echoed:
|
||||
- `mongodump --uri "$OLD_URL" --db miti99bot --archive=miti99bot-<date>.archive --gzip`
|
||||
Keep this archive outside the repo as the backup.
|
||||
- `mongorestore --uri "$NEW_URL" --archive=... --gzip --nsFrom 'miti99bot.*' --nsTo 'tiennm99bot.*'`
|
||||
This restores indexes too.
|
||||
- Compare `countDocuments` for every collection in both DBs with `mongosh`.
|
||||
Any mismatch means stop and fix before going further.
|
||||
- A first copy already ran on 2026-10-07 15:10 from `.env` `OLD_MONGO_URL`
|
||||
/ `OLD_MONGO_DATABASE` (249 docs, counts and indexes match; backup
|
||||
`~/backups/mongo/miti99bot-20261007-1510.archive.gz`). The old bot kept
|
||||
writing after it, so at cutover use `--drop` on the restore to re-sync.
|
||||
- Docker bind mounts land on the daemon host, not this workspace: stream
|
||||
with `--archive` to stdout/stdin instead of `--out`.
|
||||
3. **[you] Update the Coolify app env** in the dashboard (the MCP never writes
|
||||
secret values):
|
||||
- `TELEGRAM_BOT_TOKEN` = the new token
|
||||
- `MONGO_URL` = the new user's URL
|
||||
- `MONGO_DATABASE=tiennm99bot`
|
||||
- Optionally `BOT_USERNAME=tiennm99bot`, which skips getMe.
|
||||
- Do **not** set `STICKER_PACK_NAME`, so the default `stickers_by_tiennm99bot`
|
||||
applies.
|
||||
- Apply the same values to the preview copies, or delete those.
|
||||
4. **[you] Rename and repoint the app.** Rename the Coolify app to `tiennm99bot`
|
||||
and set its git repository to `tiennm99/tiennm99bot`, branch `main`. Check
|
||||
that the GitHub App source still sees the repo after the rename.
|
||||
5. **[agent] Deploy.** Push the phase 1 commits to `main` if they are held, or
|
||||
trigger `deploy`. Then watch `get_deployment` until it is running:healthy.
|
||||
|
||||
## C. Verify (agent checks plus you in Telegram)
|
||||
|
||||
1. **[agent]** The logs show `bot username ... source getMe|BOT_USERNAME` = `tiennm99bot`,
|
||||
`webhook cleared`, `telegram long polling started`, and no Mongo errors.
|
||||
2. **[you]** The owner receives "🚀 tiennm99bot deployed: <sha>" from @tiennm99bot.
|
||||
3. **[you]** Smoke-test in DM and in one group:
|
||||
- `/help`
|
||||
- `/stats` (old counts present, which proves the data moved)
|
||||
- `/lol`, `/stock`, `/gold`, `/coin`
|
||||
- `@tiennm99bot` inline
|
||||
- `/wheelofnames`
|
||||
4. **[you]** Run `/addsticker` replying to a sticker. It creates
|
||||
`stickers_by_tiennm99bot`. The old `miti99_by_miti99bot` pack stays with the
|
||||
old bot. Re-adding its stickers to the new pack is manual, one `/addsticker`
|
||||
each.
|
||||
5. **[you] Recapture the loldle stickers.** Send each wanted sticker to
|
||||
@tiennm99bot and capture its file_id with `/stickerid`. **[agent]** puts
|
||||
them in `internal/modules/loldle/stickers.go`, then commits and deploys.
|
||||
Until then, loldle simply sends no sticker, because the errors are already
|
||||
ignored.
|
||||
|
||||
## D. Migration window and cleanup
|
||||
|
||||
1. **[you] Move users and groups:**
|
||||
- Add @tiennm99bot to every group that used @miti99bot.
|
||||
- Users must Start the new bot in DM.
|
||||
- Stored subscriptions (lol daily push and others) are keyed by chat ID, so
|
||||
they stay valid. They deliver once the new bot is in that chat; until
|
||||
then, the fan-out logs 403s for those chats.
|
||||
- Announce the move from @miti99bot. Since its token is unused, send the
|
||||
announcement manually from your account, or post a message in each group.
|
||||
2. **[you] Retire the old bot** after the window (suggested 2–4 weeks).
|
||||
Options: `/revoke` the old token in BotFather, `/deletebot`, or keep the
|
||||
name parked to stop impersonation (recommended: keep it parked and revoke
|
||||
the token).
|
||||
3. **[agent] Drop the old database** after verification and the window. Run
|
||||
`db.getSiblingDB('miti99bot').dropDatabase()` only after a fresh count
|
||||
check, and keep the dump archive. **[you]** then delete the old Atlas user.
|
||||
4. **[agent] Clean up.** Delete the local `miti99bot` and `miti99bot-renderer`
|
||||
docker images if any remain. Update workspace memory or notes that mention
|
||||
the old name.
|
||||
|
||||
## Rollback
|
||||
|
||||
Before D.3, rollback is quick:
|
||||
1. Restore the old `TELEGRAM_BOT_TOKEN`, `MONGO_URL` and `MONGO_DATABASE`.
|
||||
2. `git revert` the rebrand commit.
|
||||
3. Redeploy.
|
||||
|
||||
The old DB is untouched until D.3. Any writes made to the new DB after cutover
|
||||
would be lost on rollback.
|
||||
|
||||
## File_id migration (done 2026-10-07 16:10)
|
||||
|
||||
- Sticker file_ids turned out to work across bots: the 6 sticker aliases and
|
||||
all 6 loldle stickers send from @tiennm99bot unchanged, so C.5 needs no code
|
||||
change. Photo and animation file_ids do not transfer.
|
||||
- The 9 photo/animation aliases were downloaded with the old token, re-uploaded
|
||||
through the new bot to the owner DM (messages deleted), send-tested, and
|
||||
their `fileId` updated only where unchanged. Three GIFs were stored without
|
||||
a file extension and had to be uploaded as `.mp4` to stay animations.
|
||||
- The 8 stickers of `miti99_by_miti99bot` were added to
|
||||
`stickers_by_tiennm99bot` by file_id (pack now 9 with the /addsticker test).
|
||||
- Backup before the update: `~/backups/mongo/tiennm99bot-20261007-pre-fileid-migration.archive.gz`.
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
title: "Rebrand miti99bot to tiennm99bot"
|
||||
description: "Rename the code, repo, image, Coolify app, Mongo database and Telegram bot from miti99bot to tiennm99bot."
|
||||
status: in-progress
|
||||
priority: P2
|
||||
effort: 1d (2h code, rest is a cutover window plus manual Telegram/Atlas steps)
|
||||
branch: main
|
||||
tags: [rebrand, deploy, mongo, telegram]
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: 2026-10-07
|
||||
---
|
||||
|
||||
# Rebrand miti99bot → tiennm99bot
|
||||
|
||||
## Outcome
|
||||
|
||||
The project, the GitHub repo, the Coolify app, the Mongo database and the
|
||||
Telegram bot are all named `tiennm99bot`. The bot runs as the new @tiennm99bot
|
||||
account with all existing data. Old @miti99bot is retired after a migration
|
||||
window.
|
||||
|
||||
Scout: [scout report](../reports/scout-261007-1423-rebrand-tiennm99bot.md).
|
||||
|
||||
## Decisions (user, 2026-10-07)
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Telegram bot | New @tiennm99bot account (new token). Bot usernames cannot be renamed. |
|
||||
| Mongo database | Rename `miti99bot` → `tiennm99bot` by dump/restore, with a backup kept. |
|
||||
| Sticker pack | New slug `stickers` → `stickers_by_tiennm99bot` (confirmed). |
|
||||
| Push timing | Hold every push to `main` until the phase 2 cutover window. |
|
||||
| Local checkout | Move to `/workspace/tiennm99/tiennm99bot`. |
|
||||
| Coolify app | Rename the app on miti-sg and repoint it to the new repo. |
|
||||
|
||||
## Constraints and non-goals
|
||||
|
||||
- `plans/**` stays untouched: those are historical records.
|
||||
- `@miti99` in `misc/handlers_test.go` is a user handle in a fixture, not the brand.
|
||||
- Owner/admin IDs are Telegram *user* IDs and do not change.
|
||||
- No data model changes. Chat IDs stored in Mongo stay valid under the new bot.
|
||||
- Prerequisite: the uncommitted `BOT_USERNAME` change lands first as its own commit.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Who | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | [Code rebrand](phase-01-code-rebrand.md) | agent | done (79104ef, 7008a57; not pushed) |
|
||||
| 2 | [Cutover and manual steps](phase-02-cutover-and-manual-steps.md) | user + agent | B, C done 2026-10-07; D (retire old bot, drop old DB) after the migration window |
|
||||
|
||||
Phase 1 can merge to `main` any time, because nothing in it depends on the new
|
||||
bot, repo or DB. Its deploy only changes branding strings and the default
|
||||
sticker pack name, though. Hold the push until the phase 2 cutover window if
|
||||
the old bot should keep its old strings until the switch.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `git grep -i miti99bot -- ':!plans'` returns nothing.
|
||||
- `go vet ./...`, `go test -race ./...`, `go build ./...`, the renderer
|
||||
`npm run lint && npm run typecheck && npm test`, and both docker builds pass.
|
||||
- Coolify app `tiennm99bot` is running:healthy from `github.com/tiennm99/tiennm99bot`.
|
||||
- The logs show `bot username ... tiennm99bot`, and the owner receives
|
||||
"🚀 tiennm99bot deployed: <sha>" from @tiennm99bot.
|
||||
- Every collection in DB `tiennm99bot` has the same document count as in `miti99bot` at cutover.
|
||||
- `/addsticker` creates or extends `stickers_by_tiennm99bot`, the loldle win/lose
|
||||
stickers send, and the inline `@tiennm99bot` alias picker works.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should old @miti99bot stay alive during the migration window to answer
|
||||
"moved to @tiennm99bot"? That would need a tiny separate process, so this
|
||||
plan leaves it idle instead. Telegram allows only one `getUpdates` consumer
|
||||
per token, so the old token is simply unused.
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
title: "Blacklist module: from brainstorm to deploy"
|
||||
date: 2026-09-15
|
||||
summary: "Shipped a passive per-topic blacklist/whitelist module; review caught a /help rune ceiling, four unpinned escaping sites, and a reply-chain scoping trap"
|
||||
---
|
||||
|
||||
# Blacklist module: from brainstorm to deploy
|
||||
|
||||
## What happened
|
||||
|
||||
Delivered `internal/modules/blacklist` end to end in one session: brainstorm, plan
|
||||
(`plans/260915-1108-blacklist-module/`), four phases, review, and push to `main` as `e4412d5`.
|
||||
|
||||
Six public commands over two per-topic lists. The module is passive by explicit decision —
|
||||
it never scans chat traffic, because enforcement would need a dispatcher message hook,
|
||||
BotFather privacy mode off, and per-group admin delete rights.
|
||||
|
||||
Four owner decisions shaped it, each settled before any code: passive registry over
|
||||
auto-moderation; whitelist as an exception layer rather than an independent list; per-thread
|
||||
and world-writable scope; and diacritic-sensitive matching (`ma`, `má`, `mà` are three
|
||||
entries).
|
||||
|
||||
## What the review caught
|
||||
|
||||
Four things a passing test suite did not:
|
||||
|
||||
1. **`/help` was 9 runes from its 4096-rune pin.** Measured directly: the module cost 480 of
|
||||
the 489 runes that were left. `RenderHelp` HTML-escapes both halves of every line, so each
|
||||
`'` costs 5 runes and each `<text...>` costs 14. Shortening six command descriptions
|
||||
restored headroom to 76. The ceiling is structural — `/help` sends one un-chunked message —
|
||||
and the next module anywhere in the repo will hit it.
|
||||
2. **Four surviving mutants.** Three HTML-escaping sites and the post-normalization byte cap
|
||||
were correct in code but pinned by no test. Fixed, and each new test was verified to fail
|
||||
with its guard removed rather than assumed to be load-bearing.
|
||||
3. **Usage strings contradicted their own `Parameters` metadata** (`<text>` vs `<text...>`),
|
||||
against `docs/command-parameter-conventions.md` item 3.
|
||||
4. **Reply-chain thread ids.** `threadOf` trusted `msg.MessageThreadID` unconditionally.
|
||||
Telegram associates a thread id with any reply chain in a supergroup, not only with a
|
||||
forum topic, so a reply-form `/blacklist_add` in a plain supergroup would have written to a
|
||||
scope no standalone `/blacklist_rules` could read back. Now gated on `IsTopicMessage`.
|
||||
|
||||
## DocStore.Scan landed mid-work
|
||||
|
||||
The push was rejected: four commits had landed on `main` meanwhile, one adding
|
||||
`DocStore.Scan` specifically to kill the List-then-Get-per-key pattern. The phase-3 design
|
||||
had copied exactly that pattern from `alias.renderNames`, and `/blacklist_rules` paid it
|
||||
twice per call. Rebased and adopted `Scan` before pushing. `/blacklist_check` still uses
|
||||
`List`, needing only entry names.
|
||||
|
||||
Worth noting for next time: a plan written against a contract can be overtaken by that
|
||||
contract while the plan is being executed. The rebase was the moment to re-read what changed,
|
||||
not just to resolve conflicts.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Per-thread entry count stays unbounded, matching `alias`. Entries remain capped at 200
|
||||
bytes each.
|
||||
- `internal/modules/module.go` shows a one-space gofmt drift under go1.27.1 but is untouched
|
||||
by this work and accepted by `golangci-lint`. Left alone rather than adding unrelated churn.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Watch `/help` — 76 runes of headroom is one command away from red. Chunking it is a shared
|
||||
surface change nobody has scoped yet.
|
||||
- Confirm on the live bot that reply-form `/blacklist_add` and a later `/blacklist_rules`
|
||||
agree in a non-forum supergroup.
|
||||
|
||||
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
title: "Gacha glint: additive blend and full-card coverage"
|
||||
date: 2026-10-02
|
||||
summary: "Mirror glint now blends additively in the card's accent tone and sweeps every corner of the card"
|
||||
---
|
||||
|
||||
# Gacha glint: additive blend and full-card coverage
|
||||
|
||||
## What happened
|
||||
- The user asked for an additive blend on the gacha card's mirror glint. If that cost too much GPU, they wanted lower alpha and a colour that matches the card's tone instead.
|
||||
- They also noticed the glint did not cover the whole card.
|
||||
|
||||
## Root cause (coverage)
|
||||
- `.wish-glint` was a card-sized box carrying a 135deg gradient, translated from (-100%,-100%) to (100%,100%).
|
||||
- A band only paints inside its own box, so as the box slid diagonally the top-right and bottom-left corners were never inside it at the moment the band passed. With a 63:88 card, the top-right corner sits about 5px outside the box when the band reaches it.
|
||||
|
||||
## Changes
|
||||
- `src/gacha/page/page.css`:
|
||||
- The glint clip uses `mix-blend-mode: plus-lighter`.
|
||||
- Band and flare colours are tinted with `--glint`, the card accent, with peak alpha around 0.75.
|
||||
- The band box is now 300% of the card, centred on it, with gradient stops rescaled by 1/3. The flare width goes from 46% to 15.3%.
|
||||
- `src/gacha/page/page.js`:
|
||||
- `--glint` is set on the clip itself, because the card flies in an overlay outside #stage.
|
||||
- The sweep now runs from translate(-26.7%) to translate(26.7%), which is 0.8 card sizes each way.
|
||||
|
||||
## Evidence
|
||||
- Docker render timings showed additive and normal blending within noise: 14.2s vs 14.4s and 11.6s vs 11.5s per clip. Headless Chrome composites in software here, and blending costs nothing measurable.
|
||||
- Frames at 3.8s (3-star) and 3.75s (5-star) show the band reaching the card edges, tinted blue and gold.
|
||||
|
||||
## Next steps
|
||||
- The user reviews the fixtures before commit.
|
||||
|
||||
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: thuyvan flood alerts for Tan Thuan
|
||||
date: 2026-10-02
|
||||
summary: Added /thuyvan tide/rain/gauge flood view and risk-only push; renamed thoitiet to weather; shared subscription helper
|
||||
---
|
||||
|
||||
# thuyvan flood alerts for Tan Thuan
|
||||
|
||||
## What happened
|
||||
|
||||
Added `/thuyvan` to the weather module, which was renamed from `thoitiet`. It
|
||||
shows Tân Thuận's flood risk from three sources: the KTTV Nam Bộ 5-day tide
|
||||
bulletin PDF (Phú An and Nhà Bè peaks), the Open-Meteo rain forecast, and VNDMS
|
||||
river gauges within 30 km. `/thuyvan_subscribe` adds a 10:30 ICT push, sent
|
||||
only when a peak reaches báo động I (1.40 m) or rain reaches 50 mm, with a
|
||||
12:30 retry. The `lol` subscriber, claim and fan-out code moved to
|
||||
`internal/modules/util/subscription`.
|
||||
|
||||
## Lessons
|
||||
|
||||
- The bulletin PDF's text layer scrambles labels ("P n A ú h"), but each
|
||||
forecast row keeps its values in order after the date token. Keying on the
|
||||
date token instead of X positions handles the swapped-date rows.
|
||||
- In some bulletins the station label sits on its own text row, 1 pt from the
|
||||
middle forecast row. All 11 real bulletins from April to October 2026 parse
|
||||
after allowing for that.
|
||||
- VNDMS returns 403 without a same-site Referer, and its old `dmc.gov.vn`
|
||||
domains now serve a "domain moved" page.
|
||||
- The review caught that a missing or stale bulletin silently turned into "no
|
||||
risk". It now errors and is retried at 12:30.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Check the Coolify `MODULES` value before pushing: if it lists `thoitiet`,
|
||||
the bot fails at startup after the rename.
|
||||
- Commit and push once that check is done.
|
||||
|
||||
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
title: Plan rebrand to tiennm99bot
|
||||
date: 2026-10-07
|
||||
summary: "Scouted and planned the miti99bot to tiennm99bot rebrand across code, repo, Coolify, Mongo and Telegram"
|
||||
---
|
||||
|
||||
# Plan rebrand to tiennm99bot
|
||||
|
||||
## What happened
|
||||
Scouted 159 files with `miti99bot`; most are the Go module path (315 import lines). External surfaces: GitHub repo, Coolify app on miti-sg (uuid ofo63lqv73huw1hce24ntg9i), Atlas DB, Telegram bot account.
|
||||
|
||||
## Decision
|
||||
New @tiennm99bot account (bot usernames cannot be renamed), Mongo DB renamed by dump/restore, new sticker slug (proposed `stickers`), checkout moved, Coolify app renamed. Plan: plans/261007-1423-rebrand-tiennm99bot/.
|
||||
|
||||
## Next steps
|
||||
Commit the pending BOT_USERNAME change, run phase 1 (code), then the phase 2 cutover with the user's BotFather/Atlas/Coolify steps.
|
||||
|
||||
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
|
||||
@@ -1,72 +0,0 @@
|
||||
# Brainstorm Decision: amlich Converter Improvements — Selected Scope
|
||||
|
||||
Date: 2026-08-18 21:58 (+07)
|
||||
Basis: `plans/reports/research-brainstorm-260818-2147-amlich-converter-improvements-report.md`
|
||||
Mode: user delegated choice ("choose the best, merge if mergable").
|
||||
|
||||
## Decision
|
||||
|
||||
All three recommended items merged into one change set — they are complementary (input UX,
|
||||
output honesty, test hardening), not alternatives. Rejections from the research report stand.
|
||||
|
||||
### In scope
|
||||
|
||||
1. **Leap-month hint in `/duonglich`** (highest user value, do first)
|
||||
- Trigger: resolved month == that lunar year's leap month AND `nhuan` flag absent.
|
||||
- Output: append "Năm nay có tháng X nhuận — thêm 'nhuan' nếu ý bạn là tháng nhuận."
|
||||
- Impl: `leapMonthOf(year int) (month int, ok bool)` helper in `lunar.go` reusing
|
||||
`getLunarMonth11` + `getLeapMonthOffset`; hint assembled in `handlers.go`.
|
||||
- NOT triggered when `nhuan` explicit (resolves research report open question 2: no).
|
||||
|
||||
2. **Razor-edge caveat in both commands**
|
||||
- Data: 7 disputed month-start JDs (new moons of 09/12/2072, 15/11/2077, 07/05/2130,
|
||||
26/05/2150, 17/05/2159, 22/01/2175, 26/01/2199) as a package-level set in `lunar.go`,
|
||||
JD values derived at implementation time and pinned by test.
|
||||
- Trigger: containing month start OR next month start is in the set → append one-line
|
||||
caveat: result near disputed lunar-month boundary, may differ ±1 day from future
|
||||
official tables.
|
||||
- Scope: months touching the boundary only, not the whole lunar year (resolves research
|
||||
report open question 1: months-only; year-wide is alarmist).
|
||||
- Impl: `nearDisputedBoundary(jdn int) bool` helper; handlers call it with the solar JD.
|
||||
|
||||
3. **Golden-table regression testdata**
|
||||
- `internal/modules/amlich/testdata/lunar-years-1800-2199.txt`: one line per year —
|
||||
year, leap-month index (0 = none), month lengths in order (12 or 13 entries).
|
||||
- Test recomputes from engine, compares byte-exact; `-update` flag idiom regenerates.
|
||||
- Rationale: round-trip test proves self-consistency only; golden table freezes the
|
||||
verified boundary placement against silent drift.
|
||||
|
||||
### Out of scope (rejected, verified in research report)
|
||||
|
||||
- ΔT model update — breaks bit-compatibility with ecosystem; verified by prior 400-year diff.
|
||||
- Pre-1968 historic mode — ground truth fragments (North UTC+8 1945–67; South UTC+7→UTC+8 1960).
|
||||
- Table-driven engine rewrite — no authoritative source beyond current engine; test-data-only instead.
|
||||
- Range extension beyond 1800–2199; can-chi day names, tiết khí, holiday lookup.
|
||||
|
||||
## Touchpoints
|
||||
|
||||
- `internal/modules/amlich/lunar.go` — `leapMonthOf`, `nearDisputedBoundary`, disputed-JD set.
|
||||
- `internal/modules/amlich/handlers.go` — hint + caveat lines in both command replies.
|
||||
- `internal/modules/amlich/lunar_test.go`, `handlers_test.go` — new cases; existing pins untouched.
|
||||
- `internal/modules/amlich/testdata/` — new golden file.
|
||||
- `docs/amlich-known-issues.md` — close open questions 1–2 with these resolutions.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- All existing tests pass unchanged, incl. `knownDates` (20/6/1944, 7/7/1967) and full round-trip.
|
||||
- `/duonglich 5/5/2028` (leap-5 year) shows hint; `/duonglich 5/5/2028 nhuan` and non-leap years don't.
|
||||
- `/amlich` for a date inside a month adjacent to 09/12/2072 boundary shows caveat; ordinary dates don't;
|
||||
`/duonglich` symmetric.
|
||||
- Golden file regenerated from HEAD is byte-identical to committed version.
|
||||
- ~100 LOC total incl. tests; no public-contract changes.
|
||||
|
||||
## Risks
|
||||
|
||||
- Caveat JD derivation error → wrong months flagged. Mitigation: pin the 7 JDs in a test that
|
||||
recomputes them from `getNewMoonDay`.
|
||||
- Reply strings are user-visible contract for handler tests — update expected strings, don't loosen asserts.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- None blocking. Post-leap-second timescale question stays parked in `docs/amlich-known-issues.md`
|
||||
open question 4.
|
||||
@@ -1,423 +0,0 @@
|
||||
# Correctness / Crash-safety / Concurrency Review — internal/modules/sticker
|
||||
|
||||
Date: 2026-08-25 · Reviewer lens: correctness, crash-safety, concurrency.
|
||||
Out of scope by assignment: cross-user security impact, test quality.
|
||||
Scope: uncommitted `internal/modules/sticker/` (~1.9k LOC non-test) plus the
|
||||
modified `cmd/server/main.go`, `internal/modules/dispatcher.go`, and the
|
||||
storage/keylock contracts they rely on.
|
||||
|
||||
`go build ./...` clean. `go vet` clean on sticker/storage/modules.
|
||||
`go test ./internal/modules/sticker/` passes.
|
||||
|
||||
## Verdict
|
||||
|
||||
The write-ahead intent state machine is sound. Every /newpack interruption point
|
||||
recovers or refuses; none wedges the user permanently on paths reachable in this
|
||||
deployment. Two real findings (H-1, H-2) undermine the *durability* half of the
|
||||
design rather than its logic. The rest is MEDIUM/LOW.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### H-1 (HIGH) — `commitContext`'s SIGTERM protection is defeated by the shutdown path
|
||||
|
||||
`cmd/server/main.go:214-228`, `internal/modules/sticker/state.go:53-59`
|
||||
|
||||
`commitContext` uses `context.WithoutCancel(ctx)` + 5s so a post-action commit
|
||||
survives SIGTERM. That only defends against *context* cancellation. The process
|
||||
does not wait for it:
|
||||
|
||||
```
|
||||
go func() { b.Start(rootCtx) }() // main.go:214 — return value never awaited
|
||||
<-rootCtx.Done() // main.go:220
|
||||
srv.Shutdown(shutdownCtx) // HTTP only
|
||||
} // main returns -> defer closeProvider() -> process exits
|
||||
```
|
||||
|
||||
`b.Start` *would* drain correctly — with `WithNotAsyncHandlers` + 1 worker
|
||||
(`telegram/client.go:28`, lib `defaultWorkers = 1`) its `wg.Wait()` blocks until
|
||||
the inline handler returns — but `main` never joins that goroutine. On SIGTERM
|
||||
`main` proceeds as soon as `srv.Shutdown` finishes (immediate with no in-flight
|
||||
HTTP), runs `defer closeProvider()` (main.go:125), and exits. The detached
|
||||
commit gets milliseconds, then the Mongo client is disconnected under it.
|
||||
|
||||
Failure scenario: deploy lands while user runs `/newpack foo Bar`.
|
||||
`CreateNewStickerSet` returns 200; `finishNewPack` → `commitPack` → detached
|
||||
`Put` starts; SIGTERM arrives; process exits before the write lands. Record stays
|
||||
`Pending:true`. Recoverable (re-run `/newpack foo …` adopts), so not data loss —
|
||||
but the write-ahead recovery path is exercised routinely rather than rarely, and
|
||||
the comment's claim ("must not be lost because the process is shutting down") is
|
||||
false as built.
|
||||
|
||||
Same exit kills `adjustCount`'s commit (count silently under-counts, never
|
||||
self-corrects) and `renamePack`'s commit (stored title diverges from Telegram
|
||||
permanently).
|
||||
|
||||
Fix direction: have `main` wait for the polling goroutine before returning —
|
||||
e.g. `pollDone := make(chan struct{}); go func(){ b.Start(rootCtx); close(pollDone) }()`
|
||||
then `select { case <-pollDone: case <-time.After(shutdownGrace): }` before
|
||||
`srv.Shutdown` / `closeProvider`. Grace must exceed `commitTimeout` (5s).
|
||||
|
||||
### H-2 (HIGH) — every reply is sent on the same exhausted context the API work drained
|
||||
|
||||
`internal/modules/sticker/state.go:62-65` and all nine handlers.
|
||||
|
||||
`handlerContext` = 10s for the whole handler. Nothing reserves a tail for the
|
||||
reply. `chathelper.Reply` sends with that same ctx, so once the budget is spent
|
||||
the user gets **no message at all** — success or failure.
|
||||
|
||||
Concrete: `/newpack mypack My Pack` replying to a **photo**.
|
||||
`resolveSource` → `downloadFile` (own 8s client timeout, but also bounded by the
|
||||
handler ctx) → `toStickerPNG` (CatmullRom on up to 4096×4096) → `UploadStickerFile`.
|
||||
On a slow link that is 7-9s. `CreateNewStickerSet` then runs on <1-3s and
|
||||
returns `context.DeadlineExceeded` — which is correctly *not* `createRefused`,
|
||||
so intent + reservation are kept — and `replyAPIError` then calls
|
||||
`reply(ctx, …)` on the dead context. User sees silence, has a `Pending` record
|
||||
and a burned reservation, and no indication what happened. `/mypack` does show
|
||||
the pending marker, so it is discoverable, not wedged.
|
||||
|
||||
The repo already has the fix pattern and this module is the only one not using
|
||||
it: `chathelper.FetchContext` reserves a 3s reply tail and is used by
|
||||
`coin/views.go:35`, `gold/handlers.go:29,185`, `stock/stock_events.go:78`,
|
||||
`monkeyd/tags_command.go:81`.
|
||||
|
||||
Fix direction: derive `fetchCtx, cancel := chathelper.FetchContext(ctx)` for the
|
||||
download/upload/Telegram calls and keep the outer `ctx` for `reply`.
|
||||
|
||||
### M-1 (MEDIUM) — post-action cleanup helpers *read* on the cancellable context
|
||||
|
||||
`pack_handlers.go:179` (`releaseSlug`), `:397` (`adjustCount`), `:432`
|
||||
(`dropPackRecordIfSet`), `:464` (`dropPackRecord`).
|
||||
|
||||
Each of these runs *after* a confirmed Telegram-side action, and each carefully
|
||||
wraps its **write** in `commitContext` — but performs the **read** it depends on
|
||||
with the caller's cancellable `ctx`. A cancelled/expired ctx therefore silently
|
||||
skips the write.
|
||||
|
||||
- `adjustCount:397` — ctx expired right after a successful `AddStickerToSet` →
|
||||
`getPack` fails → count increment never persisted. The delta is lost forever
|
||||
(the next adjust reads the stale base). Handler falls back to an in-memory
|
||||
`pack.Count++` purely for the reply, so the user is told a number that was
|
||||
never stored.
|
||||
- `releaseSlug:179` — ctx expired in the `createRefused` branch
|
||||
(`pack_handlers.go:351-354`) → reservation read fails → name stays reserved
|
||||
with no pack behind it. The owner can re-reserve (owner check passes), so the
|
||||
loss is only to other users' namespace, but it is permanent.
|
||||
- `dropPackRecord:464` — read fails → record is still deleted (correct: keeping
|
||||
it would block `/newpack`) but the slug can never be freed, because the record
|
||||
was the only thing that knew the name.
|
||||
|
||||
Fix direction: derive the commit context once at the top of each helper and use
|
||||
it for both the read and the write.
|
||||
|
||||
### M-2 (MEDIUM) — emoji: nine valid RGI emoji are refused outright
|
||||
|
||||
`emoji.go:130-156` (`isEmojiRune`). Verified by running `parseEmoji` against
|
||||
each codepoint:
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `©️` U+00A9, `®️` U+00AE | refused |
|
||||
| `〰️` U+3030, `〽️` U+303D | refused |
|
||||
| `㊗️` U+3297, `㊙️` U+3299 | refused |
|
||||
| `Ⓜ️` U+24C2 | refused |
|
||||
| `⤴️` U+2934, `⤵️` U+2935 | refused |
|
||||
|
||||
All are in Telegram's emoji keyboard. `™️` U+2122 and `ℹ️` U+2139 are special-cased
|
||||
but their neighbours are not. `/editsticker ©️` fails with "is not an emoji".
|
||||
|
||||
Fix direction: add U+00A9, U+00AE, U+2934-2935, U+3030, U+303D, U+3297, U+3299,
|
||||
U+24C2 to the singleton/range list (2900-297F would also cover the arrows).
|
||||
|
||||
### M-3 (MEDIUM) — emoji: tag-sequence flags shatter, and the refusal prints raw tag characters
|
||||
|
||||
`emoji.go:69-102`. `isBinding` covers Mn/Me but tag characters (U+E0020-E007F)
|
||||
are category **Cf**, so `🏴` (England/Scotland/Wales flags) splits into
|
||||
`["🏴", "\U000e0067", "\U000e0062", …]`. Verified output:
|
||||
|
||||
```
|
||||
in="🏴\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f"
|
||||
clusters=["🏴" "\U000e0067" … ] err="\U000e0067" is not an emoji.
|
||||
```
|
||||
|
||||
Two problems: a legitimate emoji is rejected, and `%q` renders an invisible tag
|
||||
char, so the user is told `"\U000e0067" is not an emoji`.
|
||||
|
||||
Fix direction: treat U+E0020-U+E007F as binding, terminating the cluster at
|
||||
U+E007F (cancel tag).
|
||||
|
||||
### M-4 (MEDIUM) — emoji: three inputs pass validation and send an invalid `emoji_list` to Telegram
|
||||
|
||||
`emoji.go:69-102`, `:117-128`. Verified:
|
||||
|
||||
| Input | Clusters produced | Sent to Telegram |
|
||||
|---|---|---|
|
||||
| `😀` (trailing ZWJ) | `["😀"]` | yes → `STICKER_EMOJI_INVALID` |
|
||||
| `😀🇻🇳` | `["😀🇻", "🇳"]` — the ZWJ branch (`emoji.go:83-88`) swallows the first regional indicator, orphaning the second | yes |
|
||||
| `🇻🇳🇺` (odd RI count) | `["🇻🇳", "🇺"]` — lone RI passes `isEmojiRune` via `emoji.go:154` | yes |
|
||||
|
||||
Not a crash: `apiRefusal` maps `STICKER_EMOJI_INVALID` to "Telegram rejected
|
||||
those emoji", so the user gets a sane message after one wasted API round-trip.
|
||||
Correctness bug, low impact.
|
||||
|
||||
Fix direction: reject a cluster ending in ZWJ; do not classify a lone regional
|
||||
indicator as emoji; do not let the ZWJ branch consume a regional indicator.
|
||||
|
||||
### L-1 (LOW) — `/newpack` does all its expensive work before checking whether the caller already has a pack
|
||||
|
||||
`pack_handlers.go:68-99`. `resolveSource` (photo path: GetFile + up to 2 MB
|
||||
download + resize + `UploadStickerFile`) and `resolver.resolve` (GetMe) run
|
||||
*before* the lock and before the "you already have a pack" pre-check. A user who
|
||||
already owns a pack pays a full download+upload and creates a file on Telegram's
|
||||
servers, then is refused. Wasted work only; no state divergence. Moving the
|
||||
pre-check above `resolveSource` would also buy back budget for H-2 — but note
|
||||
the pre-check must stay *after* `lockUser` and *before* `reserveSlug`, which is
|
||||
the ordering the comment at `:85-91` is defending.
|
||||
|
||||
### L-2 (LOW) — `dropPackRecord` is called both inside and outside `lockUser`
|
||||
|
||||
Inside: `handleAddSticker` (`sticker_handlers.go:80`), `handleDelSticker` (`:129`),
|
||||
`handleRenamePack` (`pack_handlers.go:544`).
|
||||
Outside: `handleEditSticker` (`sticker_handlers.go:172`), `handleOrderSticker`
|
||||
(`:210`), `handleSetPackIcon` (`setpackicon.go:45`).
|
||||
|
||||
`dropPackRecord` is a read → delete → release-slug sequence. Unlocked call sites
|
||||
could delete a record another handler just committed.
|
||||
|
||||
**Not reachable in production today**: dispatch is inline with one worker
|
||||
(`bot.WithNotAsyncHandlers()`, `defaultWorkers = 1`), the sticker module
|
||||
registers no cron job, and the dispatcher's detached per-command stats hook
|
||||
(`dispatcher.go:80-88`) writes only to the `stats` collection. Latent only —
|
||||
flagging because the inconsistency reads as an oversight rather than a decision.
|
||||
|
||||
Same class, same reachability: `handleAddSticker` resolves `pack` at
|
||||
`sticker_handlers.go:41` *before* `defer s.lockUser(...)()` at `:66`, then uses
|
||||
`pack.Name` for the API call. `adjustCount` correctly re-reads under the lock,
|
||||
but the API call itself uses the pre-lock value. `handleRenamePack` likewise
|
||||
reads at `pack_handlers.go:531` and `Put`s that whole stale record at `:550`,
|
||||
which would clobber a concurrent `Count` change.
|
||||
|
||||
### L-3 (LOW) — `/ordersticker` reports a position Telegram may not have honoured
|
||||
|
||||
`sticker_handlers.go:196-222`. Upper bound is deliberately delegated to Telegram
|
||||
(correct — a local `Count` is advisory). But if `SetStickerPositionInSet` clamps
|
||||
an out-of-range position instead of erroring, the reply "Moved to position N"
|
||||
states something false. Consider "Moved." or re-reading the set.
|
||||
|
||||
### L-4 (LOW) — `photoFileID` picks by `FileSize`, which may be absent
|
||||
|
||||
`photo.go:57-63`. `PhotoSize.FileSize` is optional in the Bot API. If Telegram
|
||||
omits it for every size, all compare equal to 0 and `Photo[0]` — the *smallest*
|
||||
thumbnail — is chosen, yielding a blurry sticker. Telegram populates it in
|
||||
practice. Tie-break on `Width*Height` instead.
|
||||
|
||||
### L-5 (LOW) — state divergence on a DB reset / collection drop
|
||||
|
||||
`pack_handlers.go:313-320`. `createOrAdopt`'s `err == nil` branch adopts any
|
||||
existing set under `<slug>_by_<bot>`, and its safety rests entirely on the
|
||||
reservation table being authoritative. If the module's collection is dropped or
|
||||
the same bot token is pointed at a fresh Mongo, the reservations vanish while the
|
||||
Telegram sets do not: the next claimant of a previously-used slug reserves it
|
||||
cleanly (`created == true`), then adopts the *previous* owner's set, and the
|
||||
record's `Count = 1` (`:363-365`) will disagree with the real set.
|
||||
|
||||
The cross-user impact belongs to the security reviewer; noting it here only as a
|
||||
Count/ownership divergence and an operational constraint. Fix direction is
|
||||
operational, not code: never drop this collection while packs exist, or gate
|
||||
adoption on `created == false`.
|
||||
|
||||
### Nit — `apiRefusal` hardcodes `120` instead of `maxStickersPerPack`
|
||||
|
||||
`errors.go:76` says "Your pack is full (120 stickers)." while
|
||||
`sticker_handlers.go:17` defines the const. The const is otherwise referenced
|
||||
only from tests.
|
||||
|
||||
---
|
||||
|
||||
## /newpack interruption-point table
|
||||
|
||||
Notation: **R** = slug reservation (`slug:<slug>`), **P** = Pack record.
|
||||
"Next `/newpack <same slug>`" and "Next `/newpack <other slug>`" are the two
|
||||
recovery entry points. All rows traced against `handleNewPack`
|
||||
(`pack_handlers.go:45-118`) and its four helpers.
|
||||
|
||||
| # | Interruption point | State left behind | Next `/newpack` **same** slug | Next `/newpack` **different** slug | Wedged? |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Before `reserveSlug` (arg/slug/title/source/GetMe failure, `:50-81`) | none | normal create | normal create | no |
|
||||
| 2 | Between pre-check (`:92`) and `reserveSlug` write | none | normal create | normal create | no |
|
||||
| 3 | After R written, before `claimSlug` (`:101-106`) | R only | `reserveSlug` conflict → own → resume (`created=false`), `claimSlug` creates P, create proceeds | R(old) orphaned; new R created; P created for new slug. Old R leaks (owner-held, re-reservable by owner) | no |
|
||||
| 4 | `claimSlug` fails/answers with `created=true` (`:106-115`) | R released at `:112` | normal create | normal create | no |
|
||||
| 5 | After P(pending) written, before `GetStickerSet` (`:106→313`) | R + P(pending) | `claimSlug` → `existing.Slug == slug` → resume → `GetStickerSet` missing → create | `resolveStaleIntent` (`:253`): R(old) held by caller, `GetStickerSet(old)` **missing** → `Put(new intent)`, `releaseSlug(old)` → create | no |
|
||||
| 6 | `GetStickerSet` returns unknown error (`:325-330`) | R + P(pending) unchanged — **deliberately untouched** | retry; succeeds once Telegram answers | as row 5 | no |
|
||||
| 7 | Between `GetStickerSet`(missing) and `CreateNewStickerSet` (`:337`) | R + P(pending) | resume → create | as row 5 | no |
|
||||
| 8 | `CreateNewStickerSet` returns a `createRefused` code (`:351-354`) | R released, P dropped | clean retry (same refusal until the cause changes) | clean create | no |
|
||||
| 9 | `CreateNewStickerSet` returns a non-refusal error (timeout/429/SIGTERM) — **set may or may not exist** (`:355`) | R + P(pending) kept | `GetStickerSet` decides: exists → adopt + commit; missing → create. Both correct | `resolveStaleIntent` probes old name: exists → **adopt old**, tell user "restored"; missing → release old, create new | no |
|
||||
| 10 | Create succeeded server-side, process dies before `finishNewPack` | R + P(pending); set exists | `GetStickerSet` → exists → `finishNewPack(adopted=true)`, `Count=1` | `resolveStaleIntent` → old set exists → adopt, refuse the new name with "restored" | no |
|
||||
| 11 | `commitPack` inside `finishNewPack` fails (`:366-369`) | R + P(pending); set exists | as row 10 → adopt + commit | as row 10 | no |
|
||||
| 12 | Process exits during the detached `commitPack` (**H-1**) | identical to row 11 | as row 10 | as row 10 | no |
|
||||
| 13 | `resolveStaleIntent` dies between `Put(new intent)` (`:296`) and `releaseSlug(old)` (`:300`) | R(old) orphaned + R(new) + P(new, pending) | resume new slug → create | probes new slug's set | no; old R leaks permanently to other users |
|
||||
| 14 | P(pending) exists but its R is now held by someone else | P(pending) + foreign R | `claimSlug` → `existing.Slug == slug` → resume → `GetStickerSet` → **if the other holder created it, this adopts their set** | `resolveStaleIntent` → `held.OwnerID != caller` → drop dead intent, proceed cleanly | see L-5 |
|
||||
|
||||
Row 14 is only reachable via the L-5 reservation-loss scenario; in normal
|
||||
operation `reserveSlug` runs before `claimSlug`, so a pending P always implies
|
||||
the caller held R at the moment P was written, and no code path transfers R
|
||||
between users (`releaseSlug:187` re-verifies ownership).
|
||||
|
||||
**Escape hatch verified**: `handleDelPack` (`delpack_callback.go:22`) does *not*
|
||||
require `!Pending`, so a user stuck on a pending record can always clear it.
|
||||
`DeleteStickerSet` on a never-created set returns `STICKERSET_INVALID`, which
|
||||
`isStickerSetMissing` treats as success (`:180`), dropping P and releasing R.
|
||||
|
||||
---
|
||||
|
||||
## Verified sound
|
||||
|
||||
Checked and found correct; no action needed.
|
||||
|
||||
**State machine / ordering**
|
||||
- Pre-check "already has a finished pack" is inside `lockUser` and before
|
||||
`reserveSlug` (`:83-99`). Reversing those two is the name-burning primitive the
|
||||
comment describes; the order is right.
|
||||
- `claimSlug` uses `PutVersioned(…, 0, …)` (create-only), never `Put`. Both
|
||||
backends give exactly one winner: Mongo via the version-0/absent filter +
|
||||
upsert + `_id` duplicate-key (`mongo_doc_store.go:85-105`); memory via
|
||||
`ErrConflict` when the key exists (`memory_provider.go:107-110`).
|
||||
- `created` is threaded correctly: `reserveSlug` returns `false` when it merely
|
||||
resumes an existing reservation (`:165`), so the bail path at `:111` never
|
||||
releases a reservation predating the invocation.
|
||||
- `releaseSlug` re-verifies `held.OwnerID == ownerID` inside the operation
|
||||
(`:187`), not at the call site.
|
||||
- `dropPackRecord` reads before deleting so the slug is still known (`:464-480`),
|
||||
and deletes the record before releasing the name — the safe ordering (the
|
||||
reverse would free a name while a record still claims it).
|
||||
- `dropPackRecordIfSet` guards on `ownsSet` (`:440`) so a stale `/delpack`
|
||||
confirmation cannot erase a newer pack's record.
|
||||
- `resolveStaleIntent` re-proves reservation ownership rather than inferring it
|
||||
from the pending record (`:258-272`).
|
||||
|
||||
**Error classification**
|
||||
- `isStickerSetMissing` requires both `bot.ErrorBadRequest` **and** the
|
||||
`STICKERSET_INVALID` substring (`errors.go:43-46`). `context.DeadlineExceeded`,
|
||||
`context.Canceled`, 429, and transport errors all fall through — they never
|
||||
authorise a record delete. Verified at all six call sites.
|
||||
- `createRefused` (`errors.go:113-123`) lists only request-validation codes and
|
||||
is kept separate from `apiRefusal` despite the overlap. Every code listed
|
||||
genuinely proves nothing was created.
|
||||
- No path infers absence from a generic failure. `createOrAdopt`'s `default`
|
||||
branch (`:325-330`) and `resolveStaleIntent`'s (`:303-307`) both change nothing.
|
||||
|
||||
**Count**
|
||||
- `adjustCount` re-reads inside `lockUser` (`:397`) rather than trusting the
|
||||
handler's pre-lock copy.
|
||||
- Clamped at 0 (`:407-410`); cannot go negative.
|
||||
- Missing record returns `storage.ErrNotFound` and does **not** recreate the
|
||||
record (`:402-405`).
|
||||
- A failed `AddStickerToSet`/`DeleteStickerFromSet` returns before `adjustCount`,
|
||||
so a failed API call never moves the count.
|
||||
- Pack-full (`STICKERS_TOO_MUCH`) returns via `replyAPIError` with no increment.
|
||||
|
||||
**Context**
|
||||
- `commitContext` used at every post-action commit: `commitPack:418`,
|
||||
`dropIntent:381`, `dropPackRecord:471`, `releaseSlug:191`,
|
||||
`dropPendingDelete:~200`, and the `/delpack` prompt persist.
|
||||
- Not used where cancellation should apply: `reserveSlug`'s and `claimSlug`'s
|
||||
pre-action writes, `resolveStaleIntent`'s intent replacement, and the pending
|
||||
action consume in the callback all use the cancellable ctx. Correct.
|
||||
- Every `context.WithTimeout` has a matching `cancel`, all `defer`red. No leaks;
|
||||
`go vet` agrees.
|
||||
|
||||
**Concurrency**
|
||||
- `keylock.Map` zero value is usable; `defer s.lockUser(id)()` acquires eagerly
|
||||
and defers only the unlock — correct idiom at all six call sites.
|
||||
- `state`'s zero-value `usernameResolver` and `nowFn == nil` are both safe
|
||||
(`state.go:47-51`, `setname.go:97-122`), so `New` not initialising them is fine.
|
||||
- `usernameResolver.resolve` never caches a failure and holds no lock across the
|
||||
`GetMe` call.
|
||||
- Slug races between two *different* users are resolved by store atomicity, not
|
||||
by `keylock` (which is per-user and could not help). Correct choice.
|
||||
- `/delpack` double-press: the pending action is consumed *before*
|
||||
`DeleteStickerSet` (`delpack_callback.go:~183`), so a second press finds
|
||||
`ErrNotFound`. Serialized dispatch makes it moot anyway.
|
||||
|
||||
**Callback safety**
|
||||
- `models.CallbackQuery.Message` is a **value** (`MaybeInaccessibleMessage`), not
|
||||
a pointer, in `go-telegram/bot v1.20.0`. `query.Message.Message` cannot nil-deref;
|
||||
the inner `*Message` nil check is the right and sufficient guard.
|
||||
- Binding check (`chat + message id + non-zero MessageID`) precedes every side
|
||||
effect including `clearButton`.
|
||||
- `parseDeleteCallback` bounds length to 64 and validates hex before use; the id
|
||||
is a lookup key only, never an authorisation input.
|
||||
|
||||
**Emoji (correct cases, verified by execution)**
|
||||
ZWJ families `👨👩👧👦`; skin tone + ZWJ `👩🏽🚀`; VS16-then-ZWJ `🏳️🌈`;
|
||||
ZWJ-then-VS16 `🏴☠️`; keycaps `1️⃣` `#️⃣`; regional-indicator pairs `🇻🇳`;
|
||||
skin-tone modifiers `👍🏿`; `⭐` (the default emoji) classifies as emoji;
|
||||
plain `A` is refused. `len(out) > 20` boundary matches Telegram's 1-20.
|
||||
`parseEmoji` returns `(nil, nil)` for empty input and `/addsticker` falls back
|
||||
correctly while `/editsticker` refuses — the right asymmetry.
|
||||
|
||||
**Image pipeline**
|
||||
- `decodeBounded` checks `DecodeConfig` before allocating pixels; rejects
|
||||
>4096 per side and `<=0` dimensions as a `userError`.
|
||||
- `scaleToLongEdge` clamps the short edge to ≥1, so 4096×1 → 512×1: no
|
||||
zero-dimension image, no divide-by-zero.
|
||||
- Compression ladder is a fixed 3-element slice — cannot loop forever. The
|
||||
`data, err =` reassignment at `image.go:64` clobbers `data` on encode error,
|
||||
but the loop unconditionally reassigns it afterwards, and a loop encode error
|
||||
returns `(nil, encErr)`. No nil-with-nil-error return.
|
||||
- `toThumbnailPNG` offsets are always ≥0 because both scaled dimensions are ≤100;
|
||||
`draw.Draw`'s `sp` correctly maps `scaled.Bounds().Min`.
|
||||
- `downloadFile` bounds by `maxSourceBytes+1` on the reader itself rather than
|
||||
trusting `Content-Length`, and closes the body.
|
||||
|
||||
**Boundaries**
|
||||
- `slugRe` `^[a-z][a-z0-9_]{2,39}$` = 3-40 chars, matching `minSlugLen`/`maxSlugLen`;
|
||||
`__` and trailing `_` are checked separately as the comment states.
|
||||
- `makeSetName`'s budget cannot go negative for any legal Telegram username (≤32
|
||||
chars → suffix 36 → budget 28).
|
||||
- No `List` calls anywhere in the module; every lookup is a keyed `Get`. No N+1.
|
||||
- `senderID` rejects `SenderChat != nil`, so anonymous group admins cannot all
|
||||
collapse onto `GroupAnonymousBot`'s single user id.
|
||||
|
||||
**Error surfacing**
|
||||
- `replyErr` shows `userError` verbatim and replaces everything else with
|
||||
`genericFailure`; `downloadFile` discards the original error entirely rather
|
||||
than wrapping, so the bot token in `FileDownloadLink` cannot reach a log via
|
||||
`errors.Unwrap`/`%v`. `classify` inspects only error *types*.
|
||||
- No path returns `nil` where the caller assumes success. The two "log and
|
||||
continue" spots (`renamePack` commit `:550-554`, `adjustCount` fallback
|
||||
`sticker_handlers.go:87-92`, `:135-141`) both follow a *confirmed* Telegram
|
||||
success, so reporting success to the user is accurate about the pack even
|
||||
though the stored count/title may lag.
|
||||
|
||||
---
|
||||
|
||||
## Recommended actions
|
||||
|
||||
1. **H-1** — join the polling goroutine in `main` with a grace period > 5s before
|
||||
`srv.Shutdown`/`closeProvider`. Without this the whole `commitContext` design
|
||||
is decorative.
|
||||
2. **H-2** — adopt `chathelper.FetchContext` in the sticker handlers so a slow
|
||||
photo pipeline cannot swallow the user's reply.
|
||||
3. **M-1** — use the commit context for the *read* as well in `adjustCount`,
|
||||
`releaseSlug`, `dropPackRecord`, `dropPackRecordIfSet`.
|
||||
4. **M-2/M-3/M-4** — emoji table additions, tag-sequence binding, and the three
|
||||
invalid-cluster rejections.
|
||||
5. **L-1** — move the "already have a pack" pre-check above `resolveSource`
|
||||
(keeping it inside `lockUser` and before `reserveSlug`).
|
||||
6. **L-2** — make `lockUser` coverage uniform across the six `dropPackRecord`
|
||||
call sites, and take the lock before the `getPack` whose value feeds the API
|
||||
call in `handleAddSticker`/`handleRenamePack`.
|
||||
7. L-3, L-4, L-5, nit — at author's discretion.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Does Telegram permanently reserve a deleted set's short name? The code
|
||||
(`pack_handlers.go:455-458`) documents this as unverified and degrades
|
||||
gracefully either way, so it is not blocking — but it decides whether
|
||||
`releaseSlug` after a `/delpack` is meaningful or purely local bookkeeping.
|
||||
2. Does `SetStickerPositionInSet` error or clamp on an out-of-range position?
|
||||
Determines whether L-3 is a false success message or a non-issue.
|
||||
3. Is the sticker collection ever dropped or re-pointed in this deployment's
|
||||
operational runbook? That is the sole trigger for L-5 / table row 14.
|
||||
@@ -1,115 +0,0 @@
|
||||
# Research Report: Văn mẫu "Đơn xin lỗi T1" cho lệnh `/xlt1`
|
||||
|
||||
Thời điểm nghiên cứu: 2026-09-02 15:47 (+07)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Meme "đơn xin lỗi" của Việt Nam là parody **đơn từ hành chính**: giữ nguyên bố cục
|
||||
công văn nhà nước (quốc hiệu, "Kính gửi", "Tôi tên là", "Nội dung sự việc",
|
||||
"Tôi xin cam kết", khối ký tên) nhưng nội dung là chuyện tầm phào. Tiếng cười đến
|
||||
từ *độ vênh* giữa hình thức trang trọng và nội dung vô nghĩa — không đến từ câu
|
||||
chữ chửi bới. Đây là phát hiện then chốt: văn mẫu `/xlt1` phải **giống công văn
|
||||
thật**, không phải một đoạn rant nữa.
|
||||
|
||||
Ngữ cảnh T1: cộng đồng LMHT Việt gọi T1 là "Tếu 1", dùng meme "tê liệt ngồi xe lăn"
|
||||
(tồn tại từ khi SKT T1 đổi tên thành T1, cả fan lẫn anti đều dùng). Motif "trù xong
|
||||
phải quay xe" là trung tâm — người viết đơn không phải anti thuần, mà là **fan đã
|
||||
mất niềm tin sớm** rồi T1 lật kèo.
|
||||
|
||||
Khuyến nghị: `/xlt1` là **hồi tiếp nối `/ff`** đã có trong repo. `/ff` = "tắt stream
|
||||
đi Tê Con ơi"; `/xlt1` = xin lỗi vì đã tắt sớm, đã trù. Cùng một nhân vật, hai thời
|
||||
điểm. Cách này vừa tái dùng dàn punchline maintainer đã chốt trong `/ff`, vừa cho
|
||||
lệnh mới một lý do tồn tại thay vì trùng thể loại.
|
||||
|
||||
## Research Methodology
|
||||
|
||||
- Sources consulted: 3 web_search (tiếng Việt) + đọc repo (`internal/modules/misc/ff_command.go`)
|
||||
- Key terms: `"đơn xin lỗi T1" văn mẫu meme`, `"xin lỗi T1" ... "Tếu 1" Faker quay xe`, `"đơn xin lỗi" "kính gửi" T1 Faker CKTG "cam kết"`
|
||||
- Giới hạn: web_search US-only; meme này sống chủ yếu trên TikTok/Facebook/Threads VN nên
|
||||
không truy được một bản "văn mẫu gốc" chuẩn. Bù lại bằng bố cục đơn hành chính VN
|
||||
(kiến thức ổn định) + dàn joke đã có trong repo.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. Bố cục "đơn xin lỗi" chuẩn (khung parody)
|
||||
|
||||
```
|
||||
CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM
|
||||
Độc lập – Tự do – Hạnh phúc
|
||||
------------------
|
||||
|
||||
ĐƠN XIN LỖI
|
||||
|
||||
Kính gửi: <đối tượng>
|
||||
Tôi tên là: <người viết>
|
||||
Địa chỉ / Chức vụ: <...>
|
||||
|
||||
Nội dung sự việc: <thừa nhận sai>
|
||||
Nay tôi làm đơn này để <mục đích>
|
||||
Tôi xin cam kết: <1..n điều>
|
||||
Kính mong <đối tượng> xem xét, tha thứ.
|
||||
Tôi xin trân trọng cảm ơn!
|
||||
|
||||
Người làm đơn
|
||||
(Ký và ghi rõ họ tên)
|
||||
```
|
||||
|
||||
Nguồn hài: giữ **đủ** các mục này. Bỏ mục nào là mất chất công văn.
|
||||
|
||||
### 2. Kho joke T1 của cộng đồng Việt (đã có trong repo, tái dùng được)
|
||||
|
||||
Từ comment của `ffTemplate` — maintainer đã chốt các motif này:
|
||||
|
||||
| Motif | Nghĩa |
|
||||
|---|---|
|
||||
| "Tếu 1" | biệt danh chọc T1 tấu hài |
|
||||
| "rạp xiếc trung ương" / "di sản văn hóa hài kịch của LCK" | T1 đá lỗi hài |
|
||||
| "gói Premium hết hạn tháng 11" | T1 chỉ đỉnh vào mùa CKTG |
|
||||
| "vía" | fan làm nghi thức cầu vía |
|
||||
| "Tê Con" | biệt danh thân mật, chính T1 cũng dùng |
|
||||
|
||||
Bổ sung từ search: meme "tê liệt / ngồi xe lăn" (fan lẫn anti dùng nhiều năm).
|
||||
|
||||
### 3. Giọng điệu nên tránh
|
||||
|
||||
- Không chửi cầu thủ theo tên (`/ff` cũng cố tình tránh: "không cần soi ai riêng đâu").
|
||||
- Không khẳng định số cúp / kết quả giải cụ thể → dữ kiện thay đổi theo mùa, template
|
||||
tĩnh sẽ lỗi thời. Nói "sự thật đã chứng minh tôi sai" là đủ, đúng mọi mùa.
|
||||
- Không toxic thật; đây là joke nội bộ nhóm chat.
|
||||
|
||||
### 4. Quyết định thiết kế lệnh
|
||||
|
||||
| Hạng mục | Chọn | Lý do |
|
||||
|---|---|---|
|
||||
| Tên | `xlt1` | khớp `^[a-z0-9_]{1,32}$` (`internal/modules/validate.go:10`) |
|
||||
| Module | `misc` | cùng chỗ với `/ff`, `/tth` |
|
||||
| Visibility | `Public` | user chốt: ai trong nhóm cũng phải làm được đơn; soi chiếu `/tth` chứ không phải `/ff` |
|
||||
| Nội suy | mention người gửi ×2 | dùng lại `senderMention()` sẵn có (DRY); lấp ô "Tôi tên là" + "Người làm đơn" |
|
||||
| Parse mode | HTML | mention cần thẻ `<a href="tg://user?id=...">`; giống `disclaimerCommand` |
|
||||
| Tham số | không | như `/ff`, args bị bỏ qua |
|
||||
|
||||
## Implementation Recommendations
|
||||
|
||||
File mới `internal/modules/misc/xlt1_command.go`, một `const` template + một
|
||||
`xlt1Command()`; đăng ký trong `New()` ngay sau `ffCommand()`. Test soi chiếu
|
||||
`TestFF_*` trong `handlers_test.go`; cập nhật map trong
|
||||
`TestNew_RegistersExpectedCommands`; cập nhật bảng module ở `README.md:11`.
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
- Template chèn qua `ReplyHTML` → tuyệt đối không để `<`, `&`, `>` thô trong văn mẫu,
|
||||
không thì Telegram trả 400. Dùng `–`, `oOo`, `...` thay vì `<...>`.
|
||||
- `senderMention` đã `html.EscapeString` phần tên; target/prose tĩnh thì tự lo.
|
||||
|
||||
## Resources & References
|
||||
|
||||
- [Bộ sưu tập meme xin lỗi](https://yeuvanhoc.edu.vn/meme-xin-loi/) — thể loại "đơn xin lỗi" như công văn parody
|
||||
- [Mẫu Đơn Xin Lỗi Meme](https://theselfishmeme.co.uk/don-xin-loi-meme) — biến thể bố cục
|
||||
- [Threads: meme tê liệt xe lăn T1](https://www.threads.com/@ghuyph_/post/DQtlGBYj-gm/) — lịch sử meme, fan lẫn anti dùng
|
||||
- [Faker – chức vô địch thứ 4](https://bytuanhuynh.substack.com/p/faker-chuc-vo-ich-thu-4-inh-cao-thich) — motif "hết thời rồi lật kèo"
|
||||
- Repo: `internal/modules/misc/ff_command.go` — dàn punchline đã chốt
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. ~~`Protected` hay `Public`?~~ Đã chốt `Public` (2026-09-02).
|
||||
2. Có cần alias (`/xinloit1`)? Hiện chỉ làm đúng `/xlt1` như yêu cầu.
|
||||
@@ -1,129 +0,0 @@
|
||||
# /giaxang — Vietnam retail fuel price source and design
|
||||
|
||||
Researched: 2026-09-30 11:27 (Asia/Saigon)
|
||||
|
||||
## Outcome
|
||||
|
||||
Use Petrolimex's own CMS JSON endpoint. It returns all six retail products with
|
||||
Zone 1 and Zone 2 prices in one unauthenticated GET, was verified live from this
|
||||
Oracle ARM64 host with plain `curl` (no special headers), and its numbers match
|
||||
today's press reports (E10 RON 95-III 27,080 / 27,620 đ, effective 2026-09-24).
|
||||
|
||||
## Source evaluation
|
||||
|
||||
| Source | Verdict | Evidence |
|
||||
|---|---|---|
|
||||
| Petrolimex CMS JSON (`portals.petrolimex.com.vn/~apis/portals/cms.item/search`) | **Use** | Live 200 response, 6 items, `Zone1Price`/`Zone2Price` numeric, `LastModified` per item. First-party data. |
|
||||
| VNAppMob (already used by `gold`) | Not available | Docs list only Province, Gold v2, Exchange rate v2; `/api/v2/petrol*` returns 404. |
|
||||
| petrolimex.com.vn HTML | Reject | Price table is rendered client-side (jQuery + crypto-js + rsa.js); nothing to parse server-side. |
|
||||
| Third-party aggregators (vietfuel-api, toanqng/fuel JSON on GitHub) | Reject as primary | Extra hop on someone's hobby infrastructure that itself scrapes Petrolimex. |
|
||||
| News sites (thanhnien, baomoi) | Reject | HTML scraping, unstable markup. |
|
||||
|
||||
## Endpoint contract
|
||||
|
||||
```text
|
||||
GET https://portals.petrolimex.com.vn/~apis/portals/cms.item/search
|
||||
?object-identity=search
|
||||
&x-request=<base64 JSON filter>
|
||||
```
|
||||
|
||||
The `x-request` value is base64 of a fixed filter (SystemID
|
||||
`6783dc1271ff449e95b74a9520964169`, RepositoryID
|
||||
`a95451e23b474fe5886bfb7cf843f53c`, RepositoryEntityID
|
||||
`3801378fe1e045b1afa10de7c5776124`, `Status=Published`, sorted by
|
||||
`LastModified` descending). Build it from a JSON literal in code rather than
|
||||
pasting an opaque base64 blob, so the IDs stay readable.
|
||||
|
||||
Fields used from each `Objects[]` entry:
|
||||
|
||||
| Field | Example | Use |
|
||||
|---|---|---|
|
||||
| `Title` | `Xăng E10 RON 95-III` | Product label |
|
||||
| `Zone1Price` | `27080` | VND/liter, vùng 1 |
|
||||
| `Zone2Price` | `27620` | VND/liter, vùng 2 |
|
||||
| `DIsplayOrder` (sic) | `4` | Sort order |
|
||||
| `LastModified` | `2026-09-24T07:47:51.496Z` | "Áp dụng từ" timestamp (max over items) |
|
||||
|
||||
Current product list: Xăng E10 RON 95-V, Xăng E10 RON 95-III, Xăng E5 RON 92-II,
|
||||
DO 0,001S-V, DO 0,05S-II, Dầu hỏa 2-K. Render whatever titles the API returns;
|
||||
do not hardcode the list (it changed with the 2026 E10 rollout).
|
||||
|
||||
## Brainstorm contract
|
||||
|
||||
- **Outcome:** `/giaxang` in the `misc` module replies with the current
|
||||
Petrolimex retail price table (all products, both zones, VND/liter) plus the
|
||||
effective timestamp in Asia/Saigon time.
|
||||
- **Constraints:** Go, follow existing `misc`/`gold` patterns; HTTP timeout under
|
||||
the handler deadline via `chathelper.FetchContext`; https-only endpoint with a
|
||||
test-overridable base URL (`httptest`); command metadata per
|
||||
`docs/command-parameter-conventions.md`; update README module table.
|
||||
- **Non-goals:** price history, change alerts/cron, PVOil or other retailers,
|
||||
per-province lookup, storing prices in MongoDB.
|
||||
- **Acceptance criteria:** handler test against an `httptest` server asserts the
|
||||
exact rendered text; upstream failure (non-200, bad JSON, empty list) yields a
|
||||
friendly Vietnamese error reply, not silence; registration/help tests pass;
|
||||
`go test ./...`, `go vet ./...`, `golangci-lint run` clean.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
| Approach | Load-bearing assumption | Fails first when |
|
||||
|---|---|---|
|
||||
| A. Fetch Petrolimex on every call (recommended) | Endpoint stays public and unauthenticated | Petrolimex adds auth/encryption to `~apis` or changes the IDs |
|
||||
| B. Fetch + in-memory cache (e.g. 10 min) | Same as A | Same as A; also adds staleness logic for little gain at chat volume |
|
||||
| C. Third-party aggregator JSON | Hobby project stays maintained | Maintainer stops updating (toanqng last push 2026-09-24, vietfuel 2026-07-16) |
|
||||
|
||||
Recommend A. Prices change at most weekly (Thursday adjustments), traffic is
|
||||
chat-scale, and the fetch is one small GET, so a cache is not needed yet.
|
||||
|
||||
Better approaches: none — recommended direction is the requested one (first-party
|
||||
JSON endpoint verified live).
|
||||
|
||||
## Proposed reply format
|
||||
|
||||
```text
|
||||
⛽ Giá bán lẻ xăng dầu Petrolimex
|
||||
Áp dụng từ 15:00 24/09/2026
|
||||
|
||||
Xăng E10 RON 95-V 28.080 | 28.640
|
||||
Xăng E10 RON 95-III 27.080 | 27.620
|
||||
Xăng E5 RON 92-II 26.390 | 26.910
|
||||
DO 0,001S-V 32.090 | 32.730
|
||||
DO 0,05S-II 30.490 | 31.090
|
||||
Dầu hỏa 2-K 30.020 | 30.620
|
||||
|
||||
đ/lít — Vùng 1 | Vùng 2
|
||||
```
|
||||
|
||||
Render inside `<pre>` (HTML) so columns align. Note: `LastModified` is when
|
||||
Petrolimex edited the record (07:47Z = 14:47 ICT), which is shortly before the
|
||||
official 15:00 effective time — label it "Cập nhật" rather than claiming the
|
||||
exact effective time.
|
||||
|
||||
## Risks
|
||||
|
||||
- Unofficial API: Petrolimex may change IDs or add request signing (their site
|
||||
already loads rsa.js/crypto-js). Mitigation: clear error reply + log; the
|
||||
source lives behind one small client so swapping is cheap.
|
||||
- Petrolimex reportedly blocks some cloud IP ranges (Google). Verified working
|
||||
from this Oracle host; the production Coolify host should be checked once
|
||||
after deploy.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Add `misc/giaxang_command.go` (client + formatter + command) and register it
|
||||
in `misc.New`.
|
||||
2. Add handler tests with `httptest`; update README module row and package doc.
|
||||
3. Run tests/vet/lint, commit `feat(misc): add /giaxang retail fuel price`.
|
||||
|
||||
## Sources
|
||||
|
||||
- Petrolimex endpoint (live probe, 2026-09-30)
|
||||
- [chiraitori/vngasprice](https://github.com/chiraitori/vngasprice) — Go scraper that documents the endpoint
|
||||
- [thanhnien.vn — Giá xăng dầu hôm nay 25.9.2026](https://thanhnien.vn/gia-xang-dau-hom-nay-2592026-xang-e10-len-muc-cao-nhat-28640-dong-lit-185260925083720959.htm) — cross-check of prices
|
||||
- [TranQui004/vietfuel-api](https://github.com/TranQui004/vietfuel-api), [toanqng/fuel](https://github.com/toanqng/fuel) — aggregator alternatives
|
||||
- [VNAppMob Open API docs](https://api.vnappmob.com/) — no fuel endpoint
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Visibility: public (like `/random`) or protected? Recommended: public.
|
||||
- Show both zones, or Zone 1 only for a shorter reply? Recommended: both.
|
||||
@@ -1,123 +0,0 @@
|
||||
# Research + Brainstorm: `thoitiet` weather module
|
||||
|
||||
Conducted 2026-10-01. Scope: weather data source and command design for a new
|
||||
`thoitiet` module with today, tomorrow, and this-week forecasts, an optional
|
||||
location argument, and Ho Chi Minh City as the default.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Build a new `internal/modules/thoitiet` module on **Open-Meteo** (forecast +
|
||||
geocoding). It needs no API key, no new env var, and no storage. All payload
|
||||
fields below were verified with live requests on 2026-10-01.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Parameters | Behaviour |
|
||||
|---|---|---|
|
||||
| `/thoitiethomnay` | `[location...]` | Current conditions + today's forecast |
|
||||
| `/thoitiet` | `[location...]` | Alias of `/thoitiethomnay` (same handler) |
|
||||
| `/thoitietngaymai` | `[location...]` | Tomorrow's daily forecast |
|
||||
| `/thoitiettuannay` | `[location...]` | One line per day for the next 7 days, starting today |
|
||||
|
||||
`[location...]` follows `docs/command-parameter-conventions.md` (optional
|
||||
remaining text, so `/thoitiet Đà Lạt` works). An empty argument means Ho Chi
|
||||
Minh City. The alias is registered as a second `modules.Command` sharing the
|
||||
handler, the same pattern as `/trongtruonghop` + `/tth` in `misc`. Stats will
|
||||
count the two names separately, which matches that existing alias.
|
||||
|
||||
## Data source comparison
|
||||
|
||||
| Source | Key | Free limit | VN geocoding | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| Open-Meteo | None | 10,000/day, 5,000/h, 600/min, non-commercial | Yes, `language=vi` returns Vietnamese names | **Pick** |
|
||||
| OpenWeatherMap | Required | 1,000/day (One Call 3.0) | Yes | Needs secret + card on file |
|
||||
| WeatherAPI.com | Required | 1M/month | Yes, `lang=vi` | Needs secret; no benefit here |
|
||||
|
||||
A personal Telegram bot is non-commercial and nowhere near the limits, so
|
||||
Open-Meteo is fine without caching. Its data is CC BY 4.0, so replies end with a
|
||||
short `Nguồn: Open-Meteo` credit.
|
||||
|
||||
## Verified API behaviour
|
||||
|
||||
Geocoding: `GET https://geocoding-api.open-meteo.com/v1/search?name=<q>&count=10&language=vi`
|
||||
|
||||
| Query | Result |
|
||||
|---|---|
|
||||
| `Ho Chi Minh`, `Hồ Chí Minh` | Thành phố Hồ Chí Minh (10.823, 106.630) |
|
||||
| `Ha Noi`, `Hanoi` | Hà Nội |
|
||||
| `Da Lat` | Ðà Lạt |
|
||||
| `Đà Lạt` (typed with diacritics) | **Wrong: "Đã Tịch", Quảng Trị** |
|
||||
| `Da Nang`, `Can Tho`, `Nha Trang`, `Hue`, `Vung Tau`, `Tokyo` | Correct |
|
||||
| `hcm`, `Thu Duc` | No results |
|
||||
| `Saigon` | A ward in Bình Thạnh, not the city |
|
||||
|
||||
Two consequences for the design. First, strip Vietnamese diacritics (including
|
||||
`Đ/đ` → `D/d`) before querying, because GeoNames stores Đà Lạt with the
|
||||
look-alike `Ð` (U+00D0) and diacritic input misses it. Second, keep a small
|
||||
alias map for common shorthand: `hcm`, `tphcm`, `sg`, `saigon`, `sai gon` → HCM;
|
||||
`hn` → Hà Nội; `dn` → Đà Nẵng. Pick the first `country_code=VN` result, else the
|
||||
first result, so foreign cities still work in a single request.
|
||||
|
||||
Forecast: `GET https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&timezone=auto&forecast_days=7`
|
||||
with
|
||||
|
||||
- `current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m`
|
||||
- `daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunrise,sunset`
|
||||
|
||||
`timezone=auto` makes "today" the location's own date. HCM returns
|
||||
`utc_offset_seconds=25200`. Weather is a WMO code that maps to a Vietnamese
|
||||
label plus emoji (about 28 codes, for example 0 Trời quang ☀️, 3 Nhiều mây ☁️,
|
||||
61–65 Mưa 🌧️, 80–82 Mưa rào 🌦️, 95–99 Dông ⛈️).
|
||||
|
||||
## Design
|
||||
|
||||
```text
|
||||
handler(range) ── parse args ──► resolveLocation(q)
|
||||
├─ empty → HCM constant (no geocoding call)
|
||||
├─ alias map → constant
|
||||
└─ geocode(stripDiacritics(q))
|
||||
──► fetchForecast(lat, lon) ──► format(range) ──► send HTML
|
||||
```
|
||||
|
||||
Files, mirroring `lol` and `giaxang`:
|
||||
|
||||
- `thoitiet.go`: `New(deps)` and the four command registrations.
|
||||
- `api_client.go`: geocode + forecast with an `*http.Client` timeout,
|
||||
`io.LimitReader`, and package-level URL vars so tests can use `httptest`.
|
||||
- `location.go`: diacritic stripping, alias map, result selection.
|
||||
- `format.go`: WMO table and the today/tomorrow/week renderers.
|
||||
- `handlers.go` and tests for each file.
|
||||
- Wiring: `cmd/server/main.go` catalog entry `"thoitiet": thoitiet.New`, the
|
||||
expected `cmd/server/command_menu_test.go` entries, and the README module table.
|
||||
|
||||
Sample `/thoitiet` reply:
|
||||
|
||||
```text
|
||||
🌤 Thời tiết hôm nay — Thành phố Hồ Chí Minh
|
||||
Hiện tại: 29°C (cảm giác 35.7°C), Nhiều mây ☁️, độ ẩm 78%, gió 3 km/h
|
||||
Cả ngày: 24–33°C, Mưa rào 🌦️, khả năng mưa 70% (5.3 mm), UV 8.9
|
||||
Mặt trời: 05:42 – 17:44
|
||||
Nguồn: Open-Meteo
|
||||
```
|
||||
|
||||
Errors: unknown location → `Không tìm thấy địa điểm "<q>".`; upstream failure →
|
||||
`Không lấy được dữ liệu thời tiết. Thử lại sau nhé.` (same tone as `/giaxang`).
|
||||
|
||||
## Rejected options
|
||||
|
||||
- Caching forecasts in Mongo: the limits make it unnecessary, and it adds a
|
||||
storage schema for no user-visible gain.
|
||||
- A per-user saved default location: not requested.
|
||||
- A keyed provider (OWM, WeatherAPI): adds a secret and setup for no gain.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Write the plan under `plans/261001-…-thoitiet-weather-module/` and implement.
|
||||
2. Run `go test ./internal/modules/thoitiet/... ./cmd/server/...`, `go vet ./...`,
|
||||
and `golangci-lint run`.
|
||||
|
||||
## Decisions
|
||||
|
||||
- `/thoitiet` is an alias of `/thoitiethomnay` (user request, 2026-10-01).
|
||||
- `/thoitiettuannay` covers the next 7 days starting today, not the Mon–Sun
|
||||
calendar week (user choice, 2026-10-01).
|
||||
@@ -1,63 +0,0 @@
|
||||
# Research: escalating the gacha wish animation by tier
|
||||
|
||||
Conducted 2026-10-01. Target: `tiennm99/wheelofnames` `src/remotion/GachaComposition.jsx`.
|
||||
|
||||
## Outcome
|
||||
|
||||
Genshin signals rarity mainly through **colour, size, and how much the screen
|
||||
is taken over**, not through different choreography. Each tier keeps the same
|
||||
beats (meteor, flash, reveal, star pops), and the higher tier gets more of
|
||||
everything: a bigger, longer meteor; a halo; more particles; a stronger flash;
|
||||
and a richer reveal. Our current composition already follows the beats but
|
||||
scales almost nothing except colour, which is why 3★, 4★, and 5★ feel the same.
|
||||
|
||||
## How the source game escalates
|
||||
|
||||
| Beat | 3★ | 4★ | 5★ |
|
||||
|---|---|---|---|
|
||||
| Meteor | Blue, thin trail | Purple, brighter, wider trail | Gold, largest, longest trail |
|
||||
| Tell before landing | none | none | rainbow-like ring forms around the star ([community report](https://genshin-impact.fandom.com/f/p/4400000000000309937)) |
|
||||
| Screen | night sky | night sky | gold light floods the screen, cascade of sparkles ([overview](https://img.krmangalam.edu.in/star-base/genshin-impact-5-star-wish-animation-secrets-1764806225)) |
|
||||
| Reveal | plain item card | character/weapon reveal with streaks | same, plus the strongest glow and the longest build-up |
|
||||
| Stars | pop in gold, one at a time | same | same; the count itself is the payoff |
|
||||
|
||||
The fandom wiki page for Wish was not reachable (HTTP 402), so the table above
|
||||
also relies on well-known gameplay behaviour; treat the 4★ "brighter trail"
|
||||
row as observed convention, not documented spec.
|
||||
|
||||
## Techniques that fit our renderer
|
||||
|
||||
The renderer draws DOM/CSS frames in Chrome headless and encodes H.264.
|
||||
Measured cost today is about 6.6–6.9 s per 7 s clip, against a 15 s
|
||||
production timeout, so there is headroom but not unlimited headroom.
|
||||
|
||||
- **Scale the existing layers by tier** (cheapest, biggest effect): meteor head
|
||||
size, trail length and sample count, spark count, ray opacity, mote count.
|
||||
- **Halo ring for 5★** on the meteor before landing, drawn as a
|
||||
`conic-gradient` rainbow ring with a radial mask, matching the in-game tell.
|
||||
- **Screen flood for 5★**: tint the sky gold as the meteor nears the ground and
|
||||
make the flash longer and warmer.
|
||||
- **Camera shake** on impact for 4★ and 5★: decaying `translate` on the whole
|
||||
frame; this is a standard impact device (Remotion templates ship one, see
|
||||
[remotion-templates](https://github.com/reactvideoeditor/remotion-templates)).
|
||||
- **Shockwave rings**: one ring for 3★, two for 4★, three plus a starburst for
|
||||
5★.
|
||||
- **Rank letter emblem** (`B`/`A`/`S` per the user's request) instead of the
|
||||
label's first character, with a heavier frame and a sheen sweep at 5★.
|
||||
- **Falling sparkle rain** behind the 5★ reveal ("raining stars").
|
||||
|
||||
`@remotion/effects` (glow, lightTrail, starburst; from v4.0.464,
|
||||
[docs](https://www.remotion.dev/docs/effects/api)) is not installed and applies
|
||||
only to specific Remotion components. Adding it would be a new dependency for
|
||||
effects we can already draw with gradients, so it is not recommended.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Keep one composition and drive every effect from a per-tier "intensity" table
|
||||
in `gacha-timeline.js`, so the escalation is data, testable, and tunable.
|
||||
Re-measure render time after the change; keep it under about 10 s.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- None blocking. The exact 4★ visual delta in the source game is convention,
|
||||
not documented.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Research: a realistic gravity fall for the gacha meteor
|
||||
|
||||
Conducted 2026-10-01. Target: `tiennm99/wheelofnames` `src/remotion/gacha-timeline.js`
|
||||
(`getMeteorState`) and `GachaComposition.jsx`.
|
||||
|
||||
## Outcome
|
||||
|
||||
Users said the fall did not look good ("Cái bay xuống chưa đẹp"). The cause:
|
||||
the meteor moved along a fixed quadratic Bézier with a quadratic ease-in on
|
||||
the curve parameter. That is not motion under gravity: it starts almost still,
|
||||
whips to the end, and its trail was spaced along the curve instead of in time,
|
||||
so it bunched up early and showed no sense of speed.
|
||||
|
||||
The fix is plain Newtonian projectile motion, which the sources agree is the
|
||||
right model: constant horizontal speed, vertical speed growing linearly under
|
||||
gravity, which traces a true parabola
|
||||
([Wikipedia: projectile motion](https://en.wikipedia.org/wiki/Projectile_motion),
|
||||
[GameDev.net](https://www.gamedev.net/forums/topic/629786-projectile-motion-parabola/4970539/)).
|
||||
|
||||
## Formula
|
||||
|
||||
With launch point `P0`, impact point `P1`, fall duration `T`, and a chosen
|
||||
launch slope `k` (vertical speed as a fraction of horizontal speed):
|
||||
|
||||
```text
|
||||
vx = (x1 - x0) / T
|
||||
vy0 = |vx| * k k = -0.3: launches slightly upward
|
||||
g = 2 * (y1 - y0 - vy0 * T) / T² solved so it lands exactly at P1
|
||||
x(t) = x0 + vx * t
|
||||
y(t) = y0 + vy0 * t + ½ g t²
|
||||
vy(t) = vy0 + g t
|
||||
```
|
||||
|
||||
Solving `g` from the endpoints keeps the impact point and timing fixed, so
|
||||
the flash and reveal choreography is unchanged. With `k = -0.3` the meteor
|
||||
climbs at about 17°, bends over, and dives at about 54° while speeding up.
|
||||
|
||||
## Supporting techniques
|
||||
|
||||
- **Motion streak sampled in time:** the trail draws the meteor's own past
|
||||
positions every 16 ms, so its length grows with speed, which is how motion
|
||||
blur reads ([Wikipedia: motion blur](https://en.wikipedia.org/wiki/Motion_blur_(media))).
|
||||
- **Velocity-aligned stretch:** the head is rotated to `atan2(vy, vx)` and
|
||||
stretched along it while thinning across it to keep its area, the standard
|
||||
squash-and-stretch for fast objects
|
||||
([Programmatic squash and stretch](http://www.alexgalbraith.nz/2019/04/05/programmatic-squash-and-stretch/)).
|
||||
- **Spark physics:** each spark inherits part of the meteor's velocity plus a
|
||||
random kick, then falls under its own gravity while fading.
|
||||
|
||||
## Verification
|
||||
|
||||
A time-lapse of rendered frames shows a clear parabolic arc with gaps that
|
||||
widen as the meteor accelerates. Tests check constant horizontal speed,
|
||||
constant vertical acceleration, the exact landing point, and the climb-to-dive
|
||||
angles. Render time is unchanged (about 9 s for 5★, 7 s for 3★/4★).
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- None.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Research: matching the gacha meteor to Genshin Impact's motion
|
||||
|
||||
Conducted 2026-10-01. Target: `tiennm99/wheelofnames` `getMeteorState` in
|
||||
`src/remotion/gacha-timeline.js` and the trail in `GachaComposition.jsx`.
|
||||
|
||||
## Outcome
|
||||
|
||||
Genshin's meteor does **not** follow a gravity arc. Measured from reference
|
||||
footage, it sweeps in along a **straight line** from the upper left at about
|
||||
23° below horizontal and **decelerates exponentially** to a near hover just
|
||||
above the horizon, where the colour reveal and the 5★ rainbow ring play. The
|
||||
gravity parabola shipped earlier was replaced with this measured curve.
|
||||
|
||||
## Method
|
||||
|
||||
Reference: the single-pull wish videos (`3star-single.mp4`,
|
||||
`4star-single.mp4`, `5star-single.mp4`; 1920x1080, 60 fps, 6 s) from the
|
||||
public fan simulator
|
||||
[AguzzTN54/Genshin-Impact-Wish-Simulator](https://github.com/AguzzTN54/Genshin-Impact-Wish-Simulator).
|
||||
They were used only to measure motion; nothing from them is shipped. Frames
|
||||
were extracted with ffmpeg at 10 fps (timeline) and 30 fps (glide), and the
|
||||
head was tracked as the brightest cluster above the cloud line.
|
||||
|
||||
## Findings
|
||||
|
||||
Timeline of the in-game single pull: cloud portal (0–1.2 s), dive with a beam
|
||||
sweep (1.2–1.6 s), **glide and hover** (1.7–3.4 s), flash (3.5 s), a streak and
|
||||
a near-vertical drop, then the result card.
|
||||
|
||||
Head position during the glide (5★, fraction of frame):
|
||||
|
||||
| t (s) | 1.80 | 2.00 | 2.20 | 2.40 | 2.80 | 3.20 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| x | 0.485 | 0.523 | 0.547 | 0.549 | 0.535 | 0.559 |
|
||||
| y | 0.509 | 0.531 | 0.540 | 0.536 | 0.543 | 0.560 |
|
||||
|
||||
- **Path:** straight, about 23° below horizontal, from off-screen upper left to
|
||||
about (0.56, 0.56).
|
||||
- **Easing:** exponential ease-out. About 88% of the distance is covered 0.5 s
|
||||
after entry, then the head creeps and hovers; rate `k ≈ 4.2 /s`.
|
||||
- **Trail:** a long straight beam from beyond the frame edge that narrows into
|
||||
the head and stays full length while the head hovers; several ribbons fan
|
||||
out along it, with sparkles floating above it.
|
||||
- **Colour:** every tier starts blue. The 4★ beam turns purple around 2.5 s;
|
||||
the 5★ head turns gold around 2.8 s and the rainbow ring forms by 3.1 s.
|
||||
|
||||
## Formula now used
|
||||
|
||||
```text
|
||||
s(t) = D * (1 - e^(-k t)) / (1 - e^(-k T)) k = 4.2, T = 2.2 s
|
||||
p(t) = hover - u * (D - s(t)) u = (cos 23°, sin 23°)
|
||||
v(t) = u * D * k * e^(-k t) / (1 - e^(-k T))
|
||||
```
|
||||
|
||||
`D` reaches from 15% beyond the left edge to the hover point (0.55, 0.55);
|
||||
normalising by `1 - e^(-kT)` lands the head exactly on the hover point.
|
||||
|
||||
## Not adopted
|
||||
|
||||
- **Blue-first colour reveal** (all tiers start blue, then change): this is
|
||||
the signature suspense beat in Genshin, but it changes how the tiers read,
|
||||
so it is left as an option for the owner.
|
||||
- The cloud portal, beam dive, and final vertical drop do not fit the 7 s
|
||||
clip without restructuring the timeline.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Should the meteor start blue for every tier and reveal its colour during
|
||||
the hover, as Genshin does?
|
||||
@@ -1,217 +0,0 @@
|
||||
# Research + Brainstorm: `/thuyvan` flood alerts for Tân Thuận
|
||||
|
||||
Conducted 2026-10-02. Scope: flood (ngập lụt) alerts for Tân Thuận, Quận 7,
|
||||
TP.HCM, through a `/thuyvan` command and an opt-in daily push. Both go into the
|
||||
current weather module, which is renamed from `thoitiet` to `weather`.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Flooding in Tân Thuận is driven mostly by high tides (triều cường) on the Sài
|
||||
Gòn and Đồng Điền rivers, and sometimes by heavy rain. The nearest gauges are
|
||||
Phú An (2.9 km) and Nhà Bè (6.8 km). Both have official alarm levels (báo động,
|
||||
BĐ) of BĐ I 1.40 m, BĐ II 1.50 m and BĐ III 1.60 m. The design uses three
|
||||
sources:
|
||||
|
||||
1. **Tide forecast:** the official 5-day tide bulletin from Đài KTTV Nam Bộ, a
|
||||
daily PDF with the peak forecast at Phú An and Nhà Bè. This is the alert
|
||||
trigger.
|
||||
2. **Rain forecast:** the Open-Meteo daily `precipitation_sum` at Tân Thuận,
|
||||
the client the module already has. This is the second trigger.
|
||||
3. **Live levels:** VNDMS readings for the stations near Tân Thuận, with their
|
||||
alarm tier. This is context, and the fallback when the bulletin is missing.
|
||||
|
||||
An alert fires when a forecast tide peak at Phú An or Nhà Bè is at least
|
||||
**1.40 m** (BĐ I), or when the day's rain forecast is at least **50 mm**.
|
||||
|
||||
## Decisions (user, 2026-10-02)
|
||||
|
||||
- Goal: flood (ngập lụt) alerts at Tân Thuận, not a general water-level tool.
|
||||
- Delivery: a `/thuyvan` command plus an opt-in daily push
|
||||
(`/thuyvan_subscribe`, `/thuyvan_unsubscribe`).
|
||||
- Trigger: a tide peak at or above BĐ I, OR rain of 50 mm or more.
|
||||
- `/thuyvan` takes **no parameter**. It always shows Tân Thuận: the flood
|
||||
forecast plus the live levels at nearby stations.
|
||||
- Placement: inside the weather module, renamed `thoitiet` → `weather`.
|
||||
- Rejected: Open-Meteo tide modelling and a `[location...]` argument.
|
||||
|
||||
## Sources (all verified live on 2026-10-02)
|
||||
|
||||
### Tide bulletin: KTTV Nam Bộ
|
||||
|
||||
- **Discovery.** The `https://kttvnb.vn/` homepage links today's article, for
|
||||
example
|
||||
`/index.php/100-thong-tin-kttv/thuy-van/29194-ba-n-tin-da-ba-o-tha-y-v-n-tphcm-ra-nga-y-02-10-2026`.
|
||||
The slug varies (one used `khu-va-c-tphcm`), so match
|
||||
`(\d+)-[^"]*tphcm-ra-nga-y-(\d{2})-(\d{2})-(\d{4})` and take the newest date.
|
||||
The `thuy-van` category page is not sorted by date, so do not use it.
|
||||
- **PDF.** The article page links
|
||||
`https://kttvnb.vn/attachments/article/<id>/HCMC_TVHN_YYYYMMDD.pdf` (318 KB,
|
||||
issued at about 09:24 ICT). The PDF path needs the article ID, so it cannot
|
||||
be guessed from the date. Plain `http://kttv-nb.org.vn` also serves the files,
|
||||
but its TLS certificate is broken, so use `https://kttvnb.vn`.
|
||||
- **Text extraction** with `github.com/ledongthuc/pdf` (`GetTextByRow`, pure
|
||||
Go) works. The labels come out with odd letter spacing (`P h ú An`), but the
|
||||
forecast number rows are clean and come in a fixed order: 5 rows for Phú An,
|
||||
5 for Nhà Bè, then 5 for Thủ Dầu Một.
|
||||
|
||||
```text
|
||||
02/ 10 1.33 07.00 1.29 21.00 - 1.79 15.00 - 0.04 01.00
|
||||
```
|
||||
|
||||
Each row is the date, peak 1 (height and time), peak 2, trough 1 and
|
||||
trough 2. `ct` means no second peak that day. Minus signs may be split from
|
||||
the number (`- 1.79`) or appear after it (`1.78 -`). Only the peaks are
|
||||
needed, and they are always positive, so the parser can drop the trough
|
||||
columns.
|
||||
- The same PDF prints the BĐ I/II/III thresholds (1.40/1.50/1.60 m), and VNDMS
|
||||
`detailRain` confirms them for both stations.
|
||||
- Today's forecast is a peak of 1.33 m at Phú An on 02/10, falling to 0.68 m by
|
||||
06/10, so no alert. The 28–30/09 peaks reached 1.56–1.60 m, BĐ II–III.
|
||||
|
||||
### Live levels: VNDMS
|
||||
|
||||
- `GET https://vndms.gov.vn/water_level?lv=0` needs a
|
||||
`Referer: https://vndms.gov.vn/` header, or it returns 403. It gives all 458
|
||||
stations as GeoJSON, with the fields inside `popupInfo` HTML (name, code,
|
||||
province, river, and `Mực nước (1.33(m) 7-02/10)` or `Không có số liệu`).
|
||||
`lv=1..3` returns only the stations at that alarm tier.
|
||||
- The response is 32 KB gzipped (432 KB decoded) and takes 0.2–1.2 s.
|
||||
- Reporting stations within 30 km of Tân Thuận today: Phú An 2.9 km, Nhà Bè
|
||||
6.8 km, Biên Hòa 25.0 km, Thủ Dầu Một 26.1 km and Bến Lức 29.1 km.
|
||||
|
||||
### Rain: Open-Meteo
|
||||
|
||||
The module's existing `fetchForecast` already returns daily `precipitation_sum`
|
||||
and `precipitation_probability_max`. Call it with a fixed Tân Thuận place.
|
||||
|
||||
### Tân Thuận point
|
||||
|
||||
Open-Meteo geocoding gives `Tân Thuận`, Quận Bảy, at
|
||||
`10.74111, 106.71806`. Store it as a constant, as `hcmPlace` is stored, because
|
||||
geocoding "Tan Thuan" by name returns 14 Vietnamese places.
|
||||
|
||||
## Design
|
||||
|
||||
```text
|
||||
/thuyvan ─┬─ tide bulletin (homepage → article → PDF → peak rows)
|
||||
├─ Open-Meteo forecast @ Tân Thuận (rain, days 0..4)
|
||||
└─ VNDMS lv=0..3 (stations ≤ 30 km, sorted by distance)
|
||||
─► merge into a per-day risk view ─► Reply
|
||||
|
||||
cron 10:30 ICT ─► same fetch ─► any day at risk? ─► push to subscribers
|
||||
no ─► send nothing
|
||||
```
|
||||
|
||||
- **Fetching.** The three sources run concurrently under one fetch context.
|
||||
Each one fails on its own: the reply omits a failed source with a short note
|
||||
and never shows guessed numbers. If the bulletin is missing (for example
|
||||
before 09:24) or fails to parse, the reply uses yesterday's bulletin when the
|
||||
homepage still links it, labelled with its issue date, or else shows only
|
||||
the live levels and the rain.
|
||||
- **Parsing safety.** A bulletin counts as valid only if it yields exactly 5
|
||||
dated rows for each of Phú An and Nhà Bè, with peaks between 0 and 3 m.
|
||||
Anything else is a parse failure, logged with the PDF URL. Tests use the
|
||||
captured `HCMC_TVHN_20261002.pdf` as a fixture.
|
||||
- **Risk per day.** Take the highest of the two peaks at Phú An and Nhà Bè and
|
||||
map it to a tier: below BĐ I, BĐ I, BĐ II or BĐ III. Rain of 50 mm or more
|
||||
flags the day too. Rain forecasts beyond about 5 days are not reliable, so
|
||||
only the bulletin's 5 days count.
|
||||
- **Push.** A cron at 10:30 ICT, after the bulletin is issued, sends one
|
||||
message per subscribed (chat, topic) only when at least one of the next 5
|
||||
days is at risk. It needs no dedupe state, because it fires once a day.
|
||||
Subscribers are stored like `lol`'s subscriber list, including the pruning
|
||||
of chats that blocked the bot. `lol/subscribers.go` and its pruning code
|
||||
would then have a second user, so move them to a shared
|
||||
`internal/modules/util` package instead of copying them.
|
||||
|
||||
Sample `/thuyvan` reply:
|
||||
|
||||
```text
|
||||
🌊 Thuỷ văn — Tân Thuận (Q.7)
|
||||
Dự báo đỉnh triều (Phú An / Nhà Bè):
|
||||
02/10: 1.33 / 1.34 m (07:00) — dưới BĐ I
|
||||
03/10: 1.18 / 1.19 m (08:00)
|
||||
04/10: 1.01 / 1.03 m (09:00)
|
||||
05/10: 0.84 / 0.88 m
|
||||
06/10: 1.13 / 0.70 m
|
||||
Mưa hôm nay: 5.3 mm (70%)
|
||||
Mực nước hiện tại:
|
||||
Phú An (Sài Gòn, 2.9 km): 1.33 m, 7h 02/10
|
||||
Nhà Bè (Đồng Điền, 6.8 km): 1.18 m, 7h 02/10
|
||||
⚠️ Thủ Dầu Một (Sài Gòn, 26.1 km): 1.56 m, 7h 02/10 — BĐ II
|
||||
BĐ I/II/III: 1.40 / 1.50 / 1.60 m
|
||||
Nguồn: Đài KTTV Nam Bộ, VNDMS, Open-Meteo
|
||||
```
|
||||
|
||||
Sample push, sent only on risk days:
|
||||
|
||||
```text
|
||||
⚠️ Cảnh báo ngập — Tân Thuận (Q.7)
|
||||
29/09: đỉnh triều 1.60 m lúc 17:00 tại Nhà Bè — BĐ III
|
||||
30/09: đỉnh triều 1.56 m — BĐ II
|
||||
Mưa 29/09: 62 mm
|
||||
Nguồn: Đài KTTV Nam Bộ, Open-Meteo
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
- Rename `internal/modules/thoitiet` to `internal/modules/weather`: the
|
||||
package, its doc, the log `module` field, and the `cmd/server/main.go` import
|
||||
and catalog key `"weather"`.
|
||||
- The module gains storage for its subscriber list, in the `weather`
|
||||
collection, with no migration. Add the `CollectionName` constant the way the
|
||||
other modules with storage do.
|
||||
- New files: `flood.go` (command, cron and risk), `tide_bulletin.go`
|
||||
(discovery and PDF parsing), `water_level.go` (VNDMS), plus tests and
|
||||
fixtures.
|
||||
- Add the dependency `github.com/ledongthuc/pdf`. It has no tagged releases,
|
||||
so `go get @latest` records a pseudo-version.
|
||||
- Add three public commands: `thuyvan`, `thuyvan_subscribe` and
|
||||
`thuyvan_unsubscribe`. Update `cmd/server/command_menu_test.go` and the
|
||||
README module row.
|
||||
- **Breaking:** `modules.Build` rejects unknown module names, so if Coolify's
|
||||
`MODULES` lists `thoitiet`, the bot **fails to start** after the rename.
|
||||
Check that value before pushing. Commit the rename on its own as
|
||||
`refactor(weather)!:`.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| The bulletin layout changes and breaks the PDF row parsing | Strict row-count and range check, fallback to live levels, a logged URL, fixture tests |
|
||||
| The homepage drops the article link or changes the slug | Loose regex; a missing link means "no bulletin" and the fallback |
|
||||
| The VNDMS Referer requirement tightens, or the domain moves again (it moved in 2025–26) | URL var, a clean failure note in the reply |
|
||||
| 50 mm/day misses short cloudbursts (40 mm in an hour floods streets too) | Use the daily total for v1; the hourly maximum is a follow-up if alerts prove too quiet |
|
||||
| The bulletin's 5 days end before a long high-tide spell | Each day's push carries the next 5 days |
|
||||
|
||||
## Changes after review (2026-10-02)
|
||||
|
||||
- A bulletin issued more than a day before today counts as missing, because
|
||||
a stalled homepage would otherwise produce a valid-looking bulletin whose
|
||||
dates no longer cover the window.
|
||||
- If the bulletin is missing and the rain forecast shows no risk, the push
|
||||
returns an error instead of reporting "no risk", since tide is the main
|
||||
signal.
|
||||
- A 12:30 ICT retry cron reruns the push. The day is claimed only when an
|
||||
alert goes out, so the retry sends nothing after a successful 10:30 push.
|
||||
- `/thuyvan` notes a failed rain forecast as well as a failed bulletin or
|
||||
gauge list.
|
||||
- Known limit: `/thuyvan` is not cached, so each call makes 8 upstream
|
||||
requests. That is acceptable at this bot's usage; add a short cache if VNDMS
|
||||
starts rate-limiting.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Check the Coolify `MODULES` value for the rename.
|
||||
2. Write the plan under `plans/261002-1205-thuyvan-flood-alert/` with phases:
|
||||
the rename, moving the subscriber code to a shared package, the parsers and
|
||||
fixtures, then the command, cron and push.
|
||||
3. Run `go test ./internal/modules/... ./cmd/server/...`, `go vet ./...` and
|
||||
`golangci-lint run`.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Push time 10:30 ICT: is that fine, or do you want a second, evening push
|
||||
before the evening high tides?
|
||||
- Should the push go out only when there is risk (as designed), or every day
|
||||
as a summary?
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
# Research + Brainstorm Report: Improving the amlich/duonglich Converter
|
||||
|
||||
Date: 2026-08-18 21:47 (+07)
|
||||
Scope: `internal/modules/amlich` — what improvements remain, ranked; what to explicitly reject.
|
||||
Inputs: repo code + `docs/amlich-known-issues.md` (prior 400-year Meeus-vs-HND diff), 5 web lookups (2 WebSearch, 3 WebFetch).
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Algorithm question is settled and should stay settled: the truncated Hồ Ngọc Đức port is bit-compatible
|
||||
with the de-facto Vietnamese ecosystem and empirically beat a full Meeus ch.49 engine on both
|
||||
historically verifiable razor-edge dates. No engine change is justified by any new evidence found.
|
||||
|
||||
Remaining improvement space is small and mostly UX/honesty, not math:
|
||||
(1) caveat line for the 7 razor-edge lunations 2072+, (2) leap-month ambiguity hint in `/duonglich`,
|
||||
(3) optional golden-table regression hardening. Everything else researched — ΔT model update,
|
||||
pre-1968 historic mode, table-driven rewrite, range extension — should be rejected; reasons below.
|
||||
|
||||
## New Research Findings (2026)
|
||||
|
||||
- **ΔT trend confirms doc's suspicion, changes nothing.** Earth set rotation-speed records in 2024–2025;
|
||||
IERS added no leap second in 2024; ~30% chance of a first-ever *negative* leap second before 2035.
|
||||
ΔT is flat-to-declining vs the polynomial's predicted growth — so the code's ΔT branch overestimates
|
||||
future ΔT. But this only matters inside the already-documented razor-edge windows. See "Reject: ΔT".
|
||||
- **Leap-second abolition adds a *new* far-future uncertainty.** CGPM votes Oct 2026 to replace the leap
|
||||
second (possibly retiring it as early as 2027). If UTC stops tracking UT1, civil UTC+7 slowly drifts
|
||||
from the astronomical time the algorithm models — same bucket as ΔT: only razor-edge relevant,
|
||||
unresolvable until Vietnam's authorities say which timescale the calendar follows.
|
||||
- **No official Vietnamese tables exist past ~2100.** Nothing from a state calendar bureau covering
|
||||
2072+ disputes surfaced. Hồ Ngọc Đức remains the de-facto ground truth; his site claims historic
|
||||
reliability "since 1301" and notes official/astronomical calendars coincide since 1976.
|
||||
- **Pre-1968 is messier than "UTC+8".** Per HND's historic-calendar page: North used UTC+8 1945–67;
|
||||
South used UTC+7 until 1959 then UTC+8 1960–67; pre-1945 rests on dynastic tables (Bách trúng kinh
|
||||
1624–1799, Khâm định vạn niên thư 1554–1903). A single "UTC+8 historic mode" (open question 2 in
|
||||
known-issues) would be *wrong for South Vietnam 1955–59* — the idea is even weaker than documented.
|
||||
- **Go ecosystem check.** Other Go ports (hungtrd/amlich, buichuongvnua/amlich, go-dyn/vcalendar) are
|
||||
straight HND ports without the day-0 overshoot fix or input validation this repo already has.
|
||||
Nothing to borrow; this implementation is ahead of them.
|
||||
|
||||
## Brainstorm: Evaluated Approaches
|
||||
|
||||
### Recommend — small, honest, non-breaking
|
||||
|
||||
**1. Razor-edge caveat in bot replies** (resolves known-issues open question 1)
|
||||
- What: hardcode the 7 disputed month-boundary JDs (09/12/2072, 15/11/2077, 07/05/2130, 26/05/2150,
|
||||
17/05/2159, 22/01/2175, 26/01/2199). When a conversion's month start or next-month start is one of
|
||||
them, append one line: result near a disputed lunar-month boundary, may differ ±1 day from future
|
||||
official tables.
|
||||
- Affects ~413 of 146,097 days; zero risk to correct output; converts a silent known-wrongness into
|
||||
stated uncertainty. Cost: a small table + one condition + tests.
|
||||
- Trade-off: nobody realistically queries 2130 from a Telegram bot — pure-YAGNI reading says skip.
|
||||
But the cost is ~30 LOC and it closes a documented open question permanently.
|
||||
|
||||
**2. Leap-month ambiguity hint in `/duonglich`** (fixes the "correct output reported as bug" item)
|
||||
- What: when the resolved (possibly defaulted) month equals that lunar year's leap month and no
|
||||
`nhuan` flag was given, append a hint: "Năm nay có tháng X nhuận — thêm 'nhuan' nếu ý bạn là
|
||||
tháng nhuận." Detection is one `getLeapMonthOffset` call on the already-computed a11.
|
||||
- Alternatives considered: (a) reply with *both* conversions — noisier, two answers where user wants
|
||||
one; (b) leave as-is — keeps a documented user-confusion source. Hint is the KISS winner:
|
||||
single authoritative answer + self-service disambiguation.
|
||||
|
||||
**3. Golden-table regression corpus** (optional hardening)
|
||||
- What: generate once, from the current verified engine, a compact per-year record (leap-month index +
|
||||
12/13 month lengths) for all 400 years; commit as testdata; test decodes and compares.
|
||||
- Why round-trip isn't enough: `TestSolarLunarRoundTrip` proves *self-consistency*; a future change
|
||||
could shift a month boundary consistently in both directions and pass. `knownDates` +
|
||||
`TestLeapMonthTable` pin samples only. A golden table freezes the full verified behavior and makes
|
||||
any future engine experiment a reviewable one-file diff.
|
||||
- Cost: ~1 generator run + ~4 KB testdata + one test. Verdict: worth it, do alongside #1.
|
||||
|
||||
### Reject — with reasons pinned
|
||||
|
||||
**ΔT model update.** New IERS data makes the polynomial *more* wrong, yet updating it is still wrong to
|
||||
do: the module's value is bit-compatibility with the reference algorithm every Vietnamese app runs.
|
||||
A "better" ΔT flips razor-edge dates away from ecosystem consensus → user-visible mismatches
|
||||
reported as bugs, with no authority to say we're right. Revisit only if official 2072+ tables appear
|
||||
(known-issues open question 4). Approach #1 (caveat) is the correct treatment of this uncertainty.
|
||||
|
||||
**Pre-1968 historic mode.** Ground truth fragments by government (North/South differ 1955–67, dynastic
|
||||
before 1945); a correct implementation is a research project, not a module feature; the proleptic
|
||||
astronomical calendar is what every reference source shows for those years anyway. Current behavior
|
||||
already matches the published record on both verifiable disputes. Keep documented, don't build.
|
||||
This *strengthens* the known-issues answer to open question 2: even a user-reported mismatch should
|
||||
trigger a doc note, not a UTC+8 mode.
|
||||
|
||||
**Table-driven rewrite.** A table must be generated from something. From this engine → just a cache of
|
||||
identical output (Go float64 JD math has ~4.5e-10-day ulp vs 0.0014-day decision margins; no
|
||||
platform-flip risk to cache away). From official tables → they don't exist past ~2100. Table-driven
|
||||
is how you'd start from scratch; with a verified engine it adds a second representation to keep in
|
||||
sync (DRY violation) for zero accuracy. The useful 20% of this idea is #3 (table as *test* data).
|
||||
|
||||
**Range extension beyond 1800–2199.** Bound exists because published references stop there; claims
|
||||
outside are unverifiable. Nothing found changes that.
|
||||
|
||||
**Feature creep** (ngày can-chi, tiết khí, giờ hoàng đạo, holiday lookup): out of scope until a user
|
||||
asks. Noted so it isn't re-brainstormed.
|
||||
|
||||
## Success Criteria (if #1–#3 are implemented)
|
||||
|
||||
- All existing tests pass unchanged, incl. `knownDates` pins (20/6/1944, 7/7/1967).
|
||||
- Caveat appears for a 2072+ razor-edge date, absent for ordinary dates (both directions of conversion).
|
||||
- `/duonglich 5/5/2028` (leap-5 year) shows hint; `/duonglich 5/5/2028 nhuan` and non-leap years don't.
|
||||
- Golden table regenerated from HEAD is byte-identical to committed testdata.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Decide which of #1/#2/#3 to implement (recommendation: all three; #2 first — most user-visible).
|
||||
2. `/ck:plan` with this report as context if proceeding; scope is small enough for a single phase.
|
||||
3. Update `docs/amlich-known-issues.md` open questions 1–3 with the resolutions above once implemented.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Hồ Ngọc Đức — Vietnamese lunar calendar](https://www.xemamlich.uhm.vn/vncal_en.html)
|
||||
- [Hồ Ngọc Đức — Historic Vietnamese lunar calendar](https://www.xemamlich.uhm.vn/histcal.html)
|
||||
- [Vietnamese calendar — Wikipedia](https://en.wikipedia.org/wiki/Vietnamese_calendar)
|
||||
- [IERS: no leap second in 2024 — DCD](https://www.datacenterdynamics.com/en/news/no-leap-seconds-added-to-universal-time-in-2024-iers-says/)
|
||||
- [Earth rotation records spur Oct 2026 CGPM vote — TechTimes](https://www.techtimes.com/articles/320185/20260711/earth-rotation-records-spur-october-vote-avert-negative-leap-second.htm)
|
||||
- [Negative leap second outlook — timeanddate](https://www.timeanddate.com/time/negative-leap-second-maybe.html)
|
||||
- [Earth rotation acceleration analysis — Astronomy Reports 2024](https://arxiv.org/html/2404.06343v3)
|
||||
- Go ports surveyed: [hungtrd/amlich](https://github.com/hungtrd/amlich), [buichuongvnua/amlich](https://pkg.go.dev/github.com/buichuongvnua/amlich), [go-dyn/vcalendar](https://pkg.go.dev/github.com/go-dyn/vcalendar)
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. Caveat wording/threshold for #1: flag only the two months touching a disputed boundary (proposed),
|
||||
or the whole lunar year? Proposed: months only — year-wide is alarmist.
|
||||
2. Should the leap-month hint (#2) also fire when month+`nhuan` *was* given but the defaulted year
|
||||
was filled in (user may have meant a different year)? Proposed: no — over-engineering.
|
||||
3. If leap seconds are abolished (Oct 2026 vote), does Vietnam's calendar follow civil UTC+7 or
|
||||
UT1+7? Unanswerable today; park with known-issues open question 4.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
title: Open-source web gacha projects as a code source for /api/gacha
|
||||
date: 2026-10-01 16:56 (Asia/Saigon)
|
||||
---
|
||||
|
||||
# Open-source web gacha projects as a code source for /api/gacha
|
||||
|
||||
## Answer
|
||||
|
||||
No candidate offers reusable code that would make the gacha wish look more like the
|
||||
real game. The realistic simulators get their look by playing MP4/WebM clips
|
||||
recorded from the commercial games. Their code is often MIT-licensed, but the
|
||||
clips belong to the publisher (HoYoverse, Nexon, and others). Mantan21's README
|
||||
says so directly: "all assets used for this application belongs to Hoyoverse".
|
||||
The projects with their own art (original games) have simple pixel or HTML
|
||||
reveals that are weaker than the current gacha wish. Copying either kind of code
|
||||
would give us one of two things: a `<video>` player that needs copyrighted
|
||||
footage, or an animation worse than what we already render.
|
||||
|
||||
## Candidates
|
||||
|
||||
Stars, licences, and dates come from the GitHub API on 2026-10-01.
|
||||
|
||||
| # | Project | Stars | Stack | Code licence | How the pull animation is made | Reusable for us? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | [Mantan21/Genshin-Impact-Wish-Simulator](https://github.com/Mantan21/Genshin-Impact-Wish-Simulator) ([live](https://wishsimulator.app)) | 293 | Svelte | MIT | Plays game-recorded clips (`3star-single.mp4` … `5star-multi.mp4`, `bg.webm`) through `_meteor.svelte` | Code yes, footage no |
|
||||
| 2 | [shadorki/genshin-impact-wish-simulator](https://github.com/shadorki/genshin-impact-wish-simulator) | 734 | JavaScript | None (all rights reserved) | Plays `dist/videos/5starwish.mp4` and similar game clips | No |
|
||||
| 3 | [Mantan21/HSR-Warp-Simulator](https://github.com/Mantan21/HSR-Warp-Simulator) ([live](https://hsr.wishsimulator.app)) | 40 | Svelte | MIT | Same author and approach, built on Star Rail game assets; the warp clip source was not checked | Code yes, assets no |
|
||||
| 4 | [jianzhishendi/Genshin-Impact-Wish-Simulator-1](https://github.com/jianzhishendi/Genshin-Impact-Wish-Simulator-1) | — | Svelte | Fork of #1 | Same as #1 | Same as #1 |
|
||||
| 5 | [U1805/ba-gacha](https://github.com/U1805/ba-gacha) | 18 | Vue | MIT | `VideoView.vue` plays Blue Archive clips per star tier | Code yes, footage no |
|
||||
| 6 | [dzmuh97/genshin-twitch-wish](https://github.com/dzmuh97/genshin-twitch-wish) | 2 | Python | None | Composites game `effect.mp4` backgrounds | No |
|
||||
| 7 | [Eling486/gacha-simulator](https://github.com/Eling486/gacha-simulator) | 23 | JavaScript | None | Arknights Spine skeletons (`.atlas`/`.skel`) extracted from the game | No |
|
||||
| 8 | [Spelljinxer/nikke-gacha-sim](https://github.com/Spelljinxer/nikke-gacha-sim) | 9 | HTML | None | Game images, no video | No |
|
||||
| 9 | [Catgenova/browsergacha](https://github.com/Catgenova/browsergacha) | 0 | Vanilla JS + canvas | None | Original pixel-art summon screen with free UI packs | No licence, and simpler than ours |
|
||||
| 10 | [HauntedBees/Public-Domains](https://github.com/HauntedBees/Public-Domains) | 1 | JavaScript | AGPL-3.0 | Public-domain art, static reveal; last push 2019 | AGPL would apply to our service; little animation to gain |
|
||||
| 11 | [DragonMarquise/GachaGameSimulator](https://github.com/DragonMarquise/GachaGameSimulator) | 7 | HTML/CSS/JS | AGPL-3.0 | Basic CSS reveal | AGPL, and simpler than ours |
|
||||
|
||||
Tracker and analyser tools (for example `biuuu/genshin-wish-export`,
|
||||
`lgou2w/HoYo.Gacha`, `bhaoo/endfield-gacha`) came up in the searches but have
|
||||
no pull animation, so they are excluded.
|
||||
|
||||
## What the code would actually give us
|
||||
|
||||
- **Video player and skip handling (#1, #5).** We already render to MP4, so
|
||||
there is nothing to gain.
|
||||
- **Pull rates and pity logic (#1).** MIT and realistic. It doesn't fit our
|
||||
contract, though: the bot picks the winner uniformly and the caller sets
|
||||
`rarity`. Adopting it would change bot behaviour, not visuals.
|
||||
- **Spine or Lottie playback (#7).** The technique could work through
|
||||
`@remotion/lottie`, but only with original animation files, and none exist
|
||||
in these repositories.
|
||||
|
||||
## Honest options from here
|
||||
|
||||
1. **Keep procedural rendering and add original painted textures**, as
|
||||
recommended in the previous discussion: images generated once with an
|
||||
image model or supplied by you, animated by the current code.
|
||||
2. **Commission or author a Lottie or Spine wish animation** and play it
|
||||
through `@remotion/lottie`. This gives the highest fidelity without game
|
||||
assets, but someone must make the animation file.
|
||||
3. **Use the game's recorded clips** as #1–#6 do. Not recommended: HoYoverse
|
||||
owns the footage, the README promises no game assets, and the bot is public.
|
||||
|
||||
## Method
|
||||
|
||||
GitHub search (`gh search repos`, `--topic gacha`), repository trees and
|
||||
READMEs read through the GitHub API, plus two web searches. I checked
|
||||
animation sources by listing video and Spine files in each tree and reading
|
||||
Mantan21's `meteor-loader.js` and `_meteor.svelte`.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Is there a commercial or Creative Commons Lottie wish animation you would
|
||||
accept, if we went with option 2?
|
||||
- How does the HSR simulator (#3) source its warp clip? I didn't check,
|
||||
because it would not change the recommendation.
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
title: Open-source gacha animations built in code (no video playback)
|
||||
date: 2026-10-01 17:03 (Asia/Saigon)
|
||||
---
|
||||
|
||||
# Open-source gacha animations built in code (no video playback)
|
||||
|
||||
## Answer
|
||||
|
||||
No mature open-source web project renders a Genshin-quality gacha pull in
|
||||
code under a licence we can use. The web projects that draw effects in code
|
||||
are general effect or particle libraries, or card-pack reveals, not wish
|
||||
sequences. Non-web projects, such as a Unity Arknights roll recreation, are
|
||||
excluded. The most promising building block is **rollshade** (MIT): a three.js
|
||||
effect library with meteors, impacts, bloom, and camera shake. It is two days
|
||||
old, though, and its update loop is stateful, which conflicts with Remotion's
|
||||
frame-by-frame rendering.
|
||||
|
||||
## Web candidates (code-built, no video)
|
||||
|
||||
Data comes from the GitHub API and the repositories' READMEs on 2026-10-01.
|
||||
"Video files" counts `.mp4`/`.webm`/`.mov` files in each repository tree.
|
||||
|
||||
| # | Project | Stars | Stack | Licence | Video files | What it is | Fit |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | [tsuyatt/rollshade](https://github.com/tsuyatt/rollshade) ([gallery](https://rollshade.tsuyatt.com/gallery/)) | 0 | three.js r186, WebGPU/TSL with WebGL2 fallback | MIT (npm `rollshade@0.1.0`) | 0 | 25 seeded spell effects (meteor, beam, impact, portal…) × 10 elements, with bloom and camera shake | Best effect source. Risks below. |
|
||||
| 3 | [briamkin/card-pack-animations](https://github.com/briamkin/card-pack-animations) | 0 | TypeScript, React wrappers | MIT | 0 | Card-pack tear and reveal | Different genre, small |
|
||||
| 4 | [paubineau/pack-cards](https://github.com/paubineau/pack-cards) | 0 | Vanilla JS and CSS | MIT | 1 (demo) | Pack wrapper, reveal animation, card materials | Different genre |
|
||||
| 5 | [Tmauc/prismo](https://github.com/Tmauc/prismo) | 0 | React, CSS-only | None | 0 | Holographic foil card effects for 10+ rarities | No licence |
|
||||
| 6 | [Takuro-U/gacha-animation](https://github.com/Takuro-U/gacha-animation) | 0 | React, Tailwind | None | 0 | Small gacha UI demo | No licence |
|
||||
| 7 | [drawcall/Proton](https://github.com/drawcall/Proton) | 2477 | JS particle engine (canvas, WebGL, pixi) | MIT | — | General particle library | Building block only |
|
||||
| 8 | [pixijs-userland/particle-emitter](https://github.com/pixijs-userland/particle-emitter) | 852 | PixiJS | MIT | — | Particle emitter with a visual editor | Building block only |
|
||||
|
||||
## Using rollshade inside Remotion
|
||||
|
||||
```js
|
||||
// rollshade's own loop: time is wall-clock deltas, state accumulates.
|
||||
fx.play(effect('meteor', 'fire', {seed: 7}), {from: start, to: target});
|
||||
fx.update(timer.getDelta());
|
||||
```
|
||||
|
||||
The risks, roughly in order of severity:
|
||||
|
||||
- **Determinism.** Remotion renders frames out of order across several
|
||||
browser tabs, but `fx.update(dt)` accumulates state. Each frame would have
|
||||
to replay from frame 0 with fixed steps (`1/fps`), so the cost grows with
|
||||
clip length, or renders would have to run with concurrency 1.
|
||||
- **Rendering speed.** The server has no GPU. WebGPU will fall back to WebGL2
|
||||
on SwiftShader, which is likely several times slower than our CSS frames.
|
||||
The gacha wish at the time already took about 15s at 854px and 30fps against the
|
||||
15s timeout.
|
||||
- **Maturity.** Version 0.1.0, first published this week, pinned to one three
|
||||
release (r186), and the API can still change.
|
||||
- **New dependencies.** `three`, `rollshade`, and `@remotion/three` with
|
||||
`@react-three/fiber`. That conflicts with our "CSS over custom rendering"
|
||||
rule in AGENTS.md, so you'd need to approve it.
|
||||
|
||||
## Options
|
||||
|
||||
1. **Prototype one shot with rollshade.** Render the meteor and impact shot in
|
||||
a separate composition, measure the render time on this server, and check
|
||||
frame determinism before committing to a rewrite. About one session of
|
||||
work, with no changes to `/api/gacha` until it proves out.
|
||||
2. **Keep our own code and borrow techniques.** Port ideas such as rollshade's
|
||||
seeded variations, bloom-like layering, and impact timing into the existing
|
||||
CSS and canvas renderer. No new dependencies, and no render-time risk.
|
||||
3. **Painted textures plus our current motion** (from the previous report).
|
||||
Not code-only, but the biggest visual gain for the cost.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Would you accept adding three.js to the renderer if the prototype renders
|
||||
inside the timeout?
|
||||
- I couldn't browse rollshade's live gallery here (no browser on this server),
|
||||
so I haven't seen its meteor effect myself.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Scout Report: rebrand miti99bot → tiennm99bot
|
||||
|
||||
Baseline: `main` @ c18006f plus the uncommitted `BOT_USERNAME` change (username
|
||||
is now runtime-resolved; sticker default = `miti99_by_<bot username>`).
|
||||
|
||||
## In-repo occurrences (159 files)
|
||||
|
||||
### Go module path — 315 import lines, ~150 files
|
||||
- `go.mod:1` `module github.com/tiennm99/miti99bot`
|
||||
- Every `internal/...` import in `cmd/` and `internal/`. Mechanical rewrite.
|
||||
- `internal/modules/util/help.go:16` `repoURL`; asserted in `util/help_test.go:62,165`.
|
||||
|
||||
### Runtime branding strings
|
||||
- `internal/server/health.go:20` — `"miti99bot ok\n"` (health body; docs quote it).
|
||||
- `internal/deploynotify/deploy_notify.go:74` — `"🚀 miti99bot deployed: %s"`; test `deploy_notify_test.go:89`.
|
||||
- User-Agent `Mozilla/5.0 (miti99bot)`: `coin/price_providers.go:261`, `gold/vnappmob_client.go:127,233`, `stock/prices_ssi.go:152`; asserted in `stock/prices_test.go:66-67`.
|
||||
- `lol/api_client.go` `userAgentProduct = "miti99bot/0.1"`; asserted in `lol/api_client_test.go` (`TestClientUserAgent`).
|
||||
- `monkeyd/export_job.go:42` — cache dir `miti99bot-monkeyd-cache` (temp dir; rename is harmless, old dir orphaned).
|
||||
- `renderer/src/gacha/page/page.js:281` — gacha recap edition label `'miti99bot'`.
|
||||
|
||||
### Sticker pack slug `miti99`
|
||||
- `sticker/sticker_pack.go:29` `defaultStickerPackSlug = "miti99"`, comment at `:79`.
|
||||
- Tests: `sticker/addsticker_command_test.go:108,136`, `sticker/sticker_pack_test.go:24`.
|
||||
- Prod sets no `STICKER_PACK_NAME`, so the live pack is the derived default `miti99_by_miti99bot`.
|
||||
|
||||
### Test fixtures (bot-agnostic, rename for consistency only)
|
||||
- `/cmd@miti99bot`: `modules/dispatcher_test.go:92,98,110,140`, `alias/fallback_test.go:43,45`, `stats/views.go:82` (comment).
|
||||
- Test DB names `miti99bot_*_test_%d`: `storage/mongo_doc_store_test.go:38`, `lol/startup_mongo_test.go:32`, `stats/startup_mongo_test.go:129`, `stock/startup_mongo_test.go:79`.
|
||||
- `sticker/sticker_pack_test.go:22-23`, `sticker/addsticker_command_test.go`.
|
||||
- `misc/handlers_test.go:257-263` uses `@miti99` as a *user* handle — not the brand; leave.
|
||||
|
||||
### Bot-scoped data
|
||||
- `loldle/stickers.go` — sticker file_ids valid only for the @miti99bot account (comment line 5).
|
||||
|
||||
### Build / deploy config
|
||||
- `.github/workflows/ci.yml:65,102` — local image tags `miti99bot`, `miti99bot-renderer`.
|
||||
- `compose.yml:6` (`ghcr.io/tiennm99/miti99bot:latest`, commented), `:12` DB example, `:53` health text.
|
||||
- `.env.example:1,12` header and `MONGO_DATABASE=miti99bot`.
|
||||
- `renderer/package.json:2`, `renderer/package-lock.json:2,8` — `miti99bot-renderer`.
|
||||
|
||||
### Docs
|
||||
- `README.md:1,289,293,295`, `AGENTS.md:5`, `CLAUDE.md:1`.
|
||||
- `docs/deploy-coolify-selfhosted.md:3,33,118,184,249`, `docs/sticker-packs.md:27,55`.
|
||||
- `renderer/README.md:3,180,181`, `renderer/docs/deployment.md:3`, `renderer/docs/miti99bot-integration.md` (file name + 3 lines).
|
||||
- `plans/**` — historical records; leave untouched.
|
||||
|
||||
## External surfaces (outside the repo)
|
||||
- GitHub repo `tiennm99/miti99bot` (git `origin`). Renaming keeps a redirect.
|
||||
- Coolify app `miti99bot` on **miti-sg** (uuid `ofo63lqv73huw1hce24ntg9i`, project "Applications", running:healthy). Env keys: `TELEGRAM_BOT_TOKEN`, `MONGO_URL`, `MONGO_DATABASE`, `OWNER_ID`, `ADMIN_IDS`, `MODULES`, `LOL_PANDASCORE_TOKEN` (+ preview copies). No `STICKER_PACK_NAME`, no `BOT_USERNAME`.
|
||||
- MongoDB Atlas database (value of `MONGO_DATABASE`; docs suggest `miti99bot`) and its least-privilege user scoped to that DB.
|
||||
- Telegram bot account @miti99bot. Bot usernames cannot be renamed; a new @tiennm99bot means a new bot + token.
|
||||
- Local checkout path `/workspace/tiennm99/miti99bot` (workspace rule: `<owner>/<repo>`).
|
||||
- No GHCR publish workflow exists; the image ref is only a comment.
|
||||
|
||||
## Unresolved Questions
|
||||
- Is the Telegram bot itself moving to a new @tiennm99bot account, or only the code/repo?
|
||||
- Should the Mongo database be renamed (requires dump/restore; Mongo has no DB rename)?
|
||||
- Should the sticker pack slug change (`tiennm99_by_…`), creating a new pack?
|
||||
@@ -1,234 +0,0 @@
|
||||
# Security review — sticker module (uncommitted)
|
||||
|
||||
Lens: security / abuse resistance only. Style, naming, test coverage out of scope.
|
||||
Method: traced attacker-controlled inputs (command args, replied message, callback payload,
|
||||
image bytes) through every store write and every Telegram call that names a set. Third pass,
|
||||
after the takeover and name-burning fixes.
|
||||
|
||||
Verified environment facts used below:
|
||||
- `internal/telegram/client.go:26-31` — `WithNotAsyncHandlers()`, single worker: updates are
|
||||
processed strictly one at a time, inline on the polling goroutine.
|
||||
- `cmd/server/main.go:266-280` — provider auto-detect: `MONGO_URL` unset ⇒ **memory backend**,
|
||||
announced with `log.Warn` only. `"sticker": sticker.New` is registered unconditionally
|
||||
(`cmd/server/main.go:95`).
|
||||
- `go-telegram/bot@v1.21.0/raw_request.go:78-81` — the library redacts the token inside
|
||||
`*url.Error.URL` for API-call failures.
|
||||
- `models.CallbackQuery.Message` is a **value** `MaybeInaccessibleMessage` holding `*Message`,
|
||||
so `query.Message.Message` cannot nil-panic.
|
||||
|
||||
---
|
||||
|
||||
## HIGH — adoption is authorised by a record that is less durable than the object it protects
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:313-321` (adopt branch), `:138-166` (reserveSlug),
|
||||
`cmd/server/main.go:266-280` (backend selection).
|
||||
|
||||
The reservation is the *only* evidence that an existing Telegram set belongs to the caller —
|
||||
`GetStickerSet` exposes no owner. The reservation lives in the module's store; the sticker set
|
||||
lives on Telegram forever. Any event that empties the store while the sets survive re-opens the
|
||||
exact cross-user takeover the reservation was added to close, with no code change.
|
||||
|
||||
Exploitation (memory backend variant — reachable by omitting `MONGO_URL`, which only logs a Warn):
|
||||
|
||||
1. Victim V: `/newpack cool My Pack` → set `cool_by_<bot>` created, share link is public by design.
|
||||
2. Bot restarts (deploy, OOM, VM reboot). Memory store is empty; Telegram set untouched.
|
||||
3. Attacker A (any user, no pack) replies to any sticker with `/newpack cool Whatever`.
|
||||
- `reserveSlug` → no reservation exists → created for A.
|
||||
- `claimSlug` → no pack record for A → pending intent written.
|
||||
- `createOrAdopt` → `GetStickerSet("cool_by_<bot>")` returns **nil error** →
|
||||
`finishNewPack(..., adopted=true)` → A's record now owns V's set.
|
||||
4. A gains, via calls that carry no owner scoping at all:
|
||||
- `/delpack` → `DeleteStickerSet{Name}` — **destroys V's pack permanently** (no user_id param).
|
||||
- `/delsticker` replying to any sticker from V's public pack — `resolveOwned` passes because
|
||||
`pack.Name == st.SetName`; `DeleteStickerFromSet{Sticker}` takes only the file id.
|
||||
- `/renamepack` → `SetStickerSetTitle{Name,Title}` — also unscoped.
|
||||
V is simultaneously locked out: V's `/newpack cool` answers `slugTaken`, `/mypack` says no pack.
|
||||
|
||||
Same primitive without the memory backend: collection dropped, `MONGO_DATABASE` changed, module
|
||||
renamed, or a Mongo restore from a backup older than the newest sets.
|
||||
|
||||
Why the existing guards do not stop it: every guard (reservation owner check, `created` scoping,
|
||||
`ownsSet`, uniform refusals) reasons entirely inside the local store. When the store is empty the
|
||||
guards are all *satisfied*, and the adopt branch is by design the path that turns "a set exists
|
||||
under a name I hold" into ownership.
|
||||
|
||||
Fix direction (cheap and precise, no new state): adoption should require that the reservation
|
||||
**pre-dated this invocation**. `reserveSlug` already computes exactly that as `created`; plumb it
|
||||
into `createOrAdopt` and, when `created == true` and `GetStickerSet` succeeds, refuse with
|
||||
`slugTaken` (plus release the just-made reservation) instead of adopting. A genuine interrupted
|
||||
attempt always re-enters with `created == false` (its reservation was written by the earlier run),
|
||||
and a set cannot exist for a reservation first written microseconds ago in this same handler — so
|
||||
this has no false negatives, and the store-wipe path can no longer adopt anything.
|
||||
Secondly: refuse to build/register this module on a non-durable provider (or `log.Fatal` when
|
||||
`KV_PROVIDER=memory` and sticker is enabled) — the module creates permanent, globally visible
|
||||
Telegram objects and must not run on a store documented as "data lost on restart".
|
||||
|
||||
## MEDIUM — cleanup helpers read on the request context but write on a detached one; slugs leak permanently
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:179` (`releaseSlug` → `getSlugReservation(ctx, …)`),
|
||||
`:464` (`dropPackRecord` → `getPack(ctx, …)`), `:432` (`dropPackRecordIfSet` → `getPack(ctx, …)`).
|
||||
|
||||
`commitContext` (`state.go:59-61`) exists precisely because SIGTERM cancels `rootCtx` mid-handler.
|
||||
It is applied to the `Delete`/`Put` calls in these helpers but **not** to the reads that decide what
|
||||
to delete. The reads therefore fail exactly in the situation the detached write was designed for.
|
||||
|
||||
Scenario (ordinary deploy, no attacker needed): user presses the `/delpack` confirm button;
|
||||
`DeleteStickerSet` succeeds; SIGTERM lands (or the 10s `handlerTimeout` expires — the handler has
|
||||
already made 2-4 API calls by then).
|
||||
- In `dropPackRecordIfSet`, `getPack` fails → returns early → the record survives naming a set that
|
||||
no longer exists. Self-heals on the next command via `STICKERSET_INVALID`, so this half is benign.
|
||||
- In `dropPackRecord` (reached from any `isStickerSetMissing` path), `getPack` fails, the pack record
|
||||
is deleted anyway on the detached context, and `pack.Slug` is never known, so the reservation is
|
||||
never released. Result: a `slug:` document with no pack and no set behind it, held against every
|
||||
other user **forever** — nothing in the module can free it (`releaseSlug` needs both owner and
|
||||
slug, and the only record of the slug was just deleted). Manual DB surgery is the only recovery.
|
||||
- `handleNewPack:111-113` has the same shape: a deadline-exceeded bail calls `releaseSlug` with the
|
||||
dead context, so the "release only what this invocation created" repair silently no-ops.
|
||||
|
||||
Fix direction: derive the commit context once at the top of `releaseSlug` / `dropPackRecord` /
|
||||
`dropPackRecordIfSet` and use it for the read as well as the write. These reads are part of the
|
||||
commit, not part of serving the request.
|
||||
|
||||
## MEDIUM — uncancellable image work stalls every user of the bot
|
||||
|
||||
`internal/modules/sticker/image.go:35` (`maxDecodeDimension = 4096`), `:63-79` (the fallback ladder,
|
||||
which re-scales from the **full-size** source `img` on every rung), reached from `/addsticker`,
|
||||
`/newpack` and `/setpackicon`.
|
||||
|
||||
`toStickerPNG` takes no context and checks none, so `handlerTimeout` bounds nothing here, and
|
||||
handlers are strictly serialized (one worker), so this is a whole-bot stall, not a per-user one.
|
||||
Measured on this box (ARM64, Go 1.27) with a 4096×4096 PNG of random 8×8 blocks — 1,278,612 bytes,
|
||||
comfortably under the 2 MiB `maxSourceBytes` cap, and its 512px downscale is per-pixel noise, so
|
||||
every PNG encode overshoots `softMaxStickerBytes` and the full ladder runs:
|
||||
|
||||
```
|
||||
decode 175 ms
|
||||
512 DefaultCompression 553 ms → 787,362 B (> 512 KiB, ladder continues)
|
||||
512 BestCompression 41 ms → 773,200 B
|
||||
448 BestCompression 522 ms → 568,571 B
|
||||
384 BestCompression 513 ms → 411,425 B
|
||||
320 BestCompression 475 ms → 280,071 B
|
||||
TOTAL 2.28 s of uninterruptible CPU per message
|
||||
```
|
||||
|
||||
One user resending that image faster than every 2.3s keeps the single dispatch goroutine saturated;
|
||||
all other users' commands queue behind it. Peak live memory is also ~64 MB for the decoded source
|
||||
alone (as the comment at `image.go:29-34` acknowledges).
|
||||
|
||||
Fix direction: scale the ladder rungs from the already-downscaled 512 image instead of `img` (drops
|
||||
three of the four expensive 4096²→N CatmullRom passes); lower `maxDecodeDimension` to ~1536-2048
|
||||
(the target is 512px, so nothing above that adds quality); optionally take `ctx` and bail between
|
||||
rungs.
|
||||
|
||||
## LOW — `/newpack` pays for the image before the check that refuses the caller
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:68` (`resolveSource`) runs before the lock, before the
|
||||
"you already have a pack" pre-check at `:92`, and before `reserveSlug`. A user who already owns a
|
||||
pack can make the bot download up to 2 MB, run the full conversion above, and call
|
||||
`UploadStickerFile` on every `/newpack`, only to be refused by a single store read that could have
|
||||
run first. Not a new primitive (the same work is legitimately available via `/addsticker`), and the
|
||||
ordering is what keeps name-burning closed, so this is cost, not a hole. Moving the cheap
|
||||
`getPack` pre-check above `resolveSource` preserves the reserve-after-precheck invariant and removes
|
||||
the free work.
|
||||
|
||||
## LOW — `handleRenamePack` commits a record it read before taking the lock
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:531-551`: `getPack` runs at `:531`, the lock is taken at
|
||||
`:540`, and `commitPack` at `:550` writes the whole document (`Count`, `Pending`, `Name`, …) from
|
||||
that pre-lock read. `adjustCount:388-395` documents exactly why that is wrong and re-reads inside
|
||||
the lock; rename does not. `handleDelPack:32-83` likewise reads and writes the pending record with
|
||||
no lock at all. Neither is exploitable today — `WithNotAsyncHandlers` + one worker means no two
|
||||
handlers ever interleave — so the locks are currently decorative and these are latent regressions
|
||||
that surface the day async handlers or a second replica are introduced.
|
||||
|
||||
---
|
||||
|
||||
## Attacked and held
|
||||
|
||||
Callback path (`delpack_callback.go`), payload fully attacker-chosen:
|
||||
- **Address someone else's confirmation** — held: the store lookup key is
|
||||
`pendingDeleteKey(query.From.ID)` (`:108`); the payload id is never used to select *whose* action
|
||||
loads, only compared for equality at `:144`.
|
||||
- **Press a bystander's button in a group** — held: `msg.Chat.ID != action.ChatID || msg.ID !=
|
||||
action.MessageID` (`:137`) is evaluated *before* any side effect, so the bystander cannot even
|
||||
strip the victim's keyboard; `clearButton` only runs after that binding passes.
|
||||
- **Replay / double-press / two live confirmations** — held: deterministic per-user key
|
||||
(`pending_delete.go:64`) so a second `/delpack` supersedes the first, and the action is consumed
|
||||
with `pending.Delete` *before* `DeleteStickerSet` (`:159-167`).
|
||||
- **Stale press after `/delpack` + `/newpack`** — held: `dropPackRecordIfSet` (`pack_handlers.go:431`)
|
||||
re-checks `ownsSet` so a stale confirmation cannot erase the record of the *new* live pack.
|
||||
- **Forwarded copy of the prompt / inaccessible message** — held: `query.Message.Message == nil`
|
||||
guard at `:125` before any use, plus the message-id binding.
|
||||
- **Malformed payload** — held: `parseDeleteCallback` bounds length to 64, requires the prefix, and
|
||||
requires lowercase hex; ids are 12 random bytes from `crypto/rand`.
|
||||
- **Anonymous / bot senders** — held for the command side: `senderID` rejects `IsBot` and
|
||||
`SenderChat != nil` (`sender.go:28-38`), so the shared GroupAnonymousBot identity can never own or
|
||||
delete a pack; `query.From.ID == 0` is rejected on the callback side.
|
||||
|
||||
Reservation lifecycle — every `slug:` create/delete site enumerated:
|
||||
create at `reserveSlug:139` only; delete at `handleNewPack:112` (only when `created`),
|
||||
`resolveStaleIntent:300` (only after a positive `STICKERSET_INVALID` on the old name),
|
||||
`createOrAdopt:353` (only when `createRefused`), `dropPackRecord:479` (only after a confirmed
|
||||
`DeleteStickerSet` or a positive `STICKERSET_INVALID`). All four funnel through `releaseSlug`,
|
||||
which re-verifies the holder itself (`:187`) rather than trusting the caller, so a delete-by-name
|
||||
cross-user primitive does not exist. Unknown/transient errors change nothing
|
||||
(`createOrAdopt:325-331`, `resolveStaleIntent:303-307`) — verified by
|
||||
`TestNewPack_UnknownLookupErrorAborts` and `TestNewPack_ResumedReservationSurvivesABail`.
|
||||
The only leak I could construct is the cancelled-context one filed as MEDIUM above.
|
||||
|
||||
- **Name burning at zero API cost** — held: the existing-pack pre-check precedes `reserveSlug`
|
||||
(`:92-101`) and the `created` flag stops a bail from releasing a resumed reservation. Sustained
|
||||
burning also needs one Telegram account per name, since one pack per user is enforced by the
|
||||
create-only `PutVersioned(packKey, 0, …)` and `/delpack` returns the name to the pool.
|
||||
- **Key-space collision across the three views over one collection** — held:
|
||||
`slugRe = ^[a-z][a-z0-9_]{2,39}$` cannot emit `:` or a leading digit, so `"slug:"+slug` is
|
||||
disjoint from decimal `packKey` and from `"pending-delete:"+decimal`; `storage.validateKey`
|
||||
additionally rejects `/`, `.`/`..` and `__ns__`, and `provider.Collection("sticker")` isolates the
|
||||
module (`registry.go:151`). Callback prefixes are conflict-checked bidirectionally
|
||||
(`registry.go:219`).
|
||||
- **Mutating another user's stickers via a replied sticker** — held: `resolveOwned` compares the
|
||||
*stored* `Pack.Name` against `Sticker.SetName`, which is authored by Telegram and not settable by
|
||||
the sender; a copied sticker lands in the copier's own set with a new file id, and the original
|
||||
message still carries the victim's `set_name`.
|
||||
- **Enumeration** — held: `slugTaken` (`pack_handlers.go:23`) is byte-identical to the
|
||||
`PACK_SHORT_NAME_OCCUPIED` mapping in `apiRefusal` (`errors.go:62`), so "reserved in this bot" and
|
||||
"occupied on Telegram" are indistinguishable; `notOwnedRefusal` is the single answer for no-pack,
|
||||
pending-pack, and foreign-set. Raw Telegram descriptions never reach a reply — `replyAPIError`
|
||||
maps or genericises.
|
||||
- **Bot-token leakage** — held on every path I could reach: the download path discards the original
|
||||
error entirely (`errDownloadFailed`, `download.go:36-83`, no `%w` of the transport error) and logs
|
||||
only `classify(err)`, which inspects types and never formats the error; API-call failures have the
|
||||
token redacted inside `url.Error.URL` by the library itself; user replies echo only `userError`
|
||||
text (`state.go:95-101`). Replies are sent with no `ParseMode`, so a 64-char attacker-chosen title
|
||||
echoed in `/mypack` and the delete prompt cannot inject markup either.
|
||||
- **Error classification** — held: `isStickerSetMissing` requires both `bot.ErrorBadRequest` and the
|
||||
`STICKERSET_INVALID` code; `createRefused` is a separate positive-only list of
|
||||
request-validation codes and is used only to authorise undoing an intent + reservation. No path
|
||||
infers absence from a generic failure.
|
||||
- **Bounds ordering** — held except as noted in LOW: MIME allowlist and `FileSize` are checked before
|
||||
any byte is fetched (`photo.go:64,74`), `GetFile.FileSize` is re-checked server-side, the body is
|
||||
read through `LimitReader(max+1)` with an explicit overflow check, `DecodeConfig` bounds dimensions
|
||||
before any pixel buffer exists, and the 20-emoji cap is enforced before the API call.
|
||||
|
||||
---
|
||||
|
||||
## Recommended actions
|
||||
|
||||
1. Gate adoption on `created == false` (reservation pre-dated this `/newpack`), and refuse to run
|
||||
this module on a non-durable store. — HIGH
|
||||
2. Use `commitContext` for the reads inside `releaseSlug` / `dropPackRecord` /
|
||||
`dropPackRecordIfSet`. — MEDIUM
|
||||
3. Scale the fallback ladder from the 512px image and lower `maxDecodeDimension`. — MEDIUM
|
||||
4. Move the existing-pack pre-check above `resolveSource` in `/newpack`. — LOW
|
||||
5. Re-read inside the lock in `handleRenamePack` (match `adjustCount`), or state in the module doc
|
||||
that serialization is guaranteed by the dispatcher and the locks are belt-and-braces. — LOW
|
||||
|
||||
## Unresolved
|
||||
|
||||
- `go test ./internal/modules/sticker/...` failed once on its first (uncached) run — tail showed two
|
||||
`sticker_newpack_lookup … upstream is unhappy` ERROR lines then `FAIL` — and then passed 25+
|
||||
consecutive runs including `-count=8` and `-race`, and under 8-way CPU load. Not reproduced, not a
|
||||
security finding, but flagging it for whoever owns the tests: the suspects are
|
||||
`TestNewPack_UnknownLookupErrorAborts` / `TestNewPack_ResumedReservationSurvivesABail`.
|
||||
- Whether Telegram permanently reserves the short name of a deleted set (plan R11) is still
|
||||
unverified. The code handles both answers correctly, so this is an open fact, not a defect.
|
||||
@@ -1,411 +0,0 @@
|
||||
# Test / harness / wiring review — sticker module
|
||||
|
||||
Reviewer lens: test quality, harness correctness, integration & wiring.
|
||||
Method: read all sources, then **mutation-tested 22 production mutations** against the
|
||||
suite. Security exploitation and deep state-machine correctness owned by other reviewers.
|
||||
|
||||
Verdict: test quality is **high** — 19 of 22 mutations killed, and the ownership gates,
|
||||
callback binding, self-heal, panic barrier and count bookkeeping are all genuinely pinned.
|
||||
Two rounds previously claimed "all new tests non-vacuous"; that is **refuted in three
|
||||
places**: the `created`/reservation-release machinery, two `/addsticker` emoji tests, and
|
||||
the module-registration wiring. None is a bug in shipped behaviour today; all are real
|
||||
holes that would let a future regression land green.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mutation-test results
|
||||
|
||||
Every mutation applied to production source only, run, then reverted. Restoration verified
|
||||
by md5 + `diff -r` (see §7).
|
||||
|
||||
| # | Mutation applied | File | Guarding test(s) | Result |
|
||||
|---|---|---|---|---|
|
||||
| M1 | Delete the `ownsSet` gate from `resolveOwned` | resolve.go:116 | `TestResolveOwned_RefusalsAreIdentical` | **KILLED** |
|
||||
| M2 | `!found \|\| pack.Pending` → `!found` | resolve.go:113 | `TestResolveOwned_PendingRefusesIdentically` | **KILLED** |
|
||||
| M3 | Disable foreign-holder refusal in `reserveSlug` | pack_handlers.go:159 | `TestNewPack_CannotSeizeAnotherUsersPack`, `..._ForeignReservationRefusedBeforeAnyAPICall` | **KILLED** |
|
||||
| M4 | Disable reservation-owner proof in `resolveStaleIntent` | pack_handlers.go:263 | `TestNewPack_StaleIntentCannotAdoptForeignName` | **KILLED** |
|
||||
| **M5** | **`if created { releaseSlug }` → never release** | **pack_handlers.go:111** | *(none)* | **SURVIVED** |
|
||||
| **M6** | **Delete the pre-reservation quota check entirely** | **pack_handlers.go:92-99** | *(none)* | **SURVIVED** |
|
||||
| **M7** | **`reserveSlug` resumed path returns `created=true`** | **pack_handlers.go:165** | *(none)* | **SURVIVED** |
|
||||
| M8 | `dropPackRecordIfSet` → `dropPackRecord` (blind by-owner delete) | delpack_callback.go:178 | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` | **KILLED** |
|
||||
| M9 | Disable chat/message binding check | delpack_callback.go:137 | `..._RejectsWrongBinding` (2 subtests), `..._BystanderCannotTouchAnotherUsersPrompt` | **KILLED** |
|
||||
| M10 | Remove consume-before-destructive-call | delpack_callback.go:159 | `TestDelPackCallback_SecondPressIsInert` | **KILLED** |
|
||||
| M11 | Disable expiry check | delpack_callback.go:149 | `TestDelPackCallback_RejectsExpired` | **KILLED** |
|
||||
| M12b | `dropPackRecord` never releases the slug | pack_handlers.go:478 | `TestSelfHeal_ReleasesTheName`, `TestDelPackCallback_ReleasesTheName` | **KILLED** |
|
||||
| M13 | `isStickerSetMissing` → `err != nil` (transient read as "gone") | errors.go:163 | 4 tests incl. both `_TransientErrorKeepsRecord` | **KILLED** |
|
||||
| M14 | Remove the negative-count floor | pack_handlers.go:407 | `TestDelSticker_CountFlooredAtZero` | **KILLED** |
|
||||
| M15 | Invert emoji precedence (replied beats explicit) | sticker_handlers.go:283 | `TestAddSticker_HappyPath`, `..._EmojiPrecedence/explicit_wins` | **KILLED** |
|
||||
| **M16** | **Early-return before `AddStickerToSet` (no API call at all)** | **sticker_handlers.go:292** | *(none — see F2)* | **SURVIVED** |
|
||||
| M17 | Drop the inherit-from-replied-sticker fallback | sticker_handlers.go:283 | `..._EmojiPrecedence/inherits_from_replied_sticker` | **KILLED** |
|
||||
| M18 | Remove the command panic barrier | dispatcher.go:75 | `TestInstall_CommandPanicIsContained` (binary would die) | **KILLED** |
|
||||
| M19 | Remove the command-hook panic barrier | dispatcher.go:83 | `TestInstall_CommandHookPanicIsContained` | **KILLED** |
|
||||
| M20 | Callback barrier `onPanic` → nil (stop answering the query) | dispatcher.go:104 | `TestInstall_CallbackPanicIsContainedAndAnswered` | **KILLED** |
|
||||
| M21 | `claimSlug` `PutVersioned(…,0,…)` → `Put` | pack_handlers.go:216 | `TestNewPack_DifferentSlugAdoptsExistingSet` | **KILLED** |
|
||||
| M22 | `reserveSlug` `PutVersioned(…,0,…)` → `Put` | pack_handlers.go:139 | `TestNewPack_CannotSeize…`, `..._ForeignReservation…` | **KILLED** |
|
||||
| **W1** | **Delete `"mypack": ""` from `expectedParameters`** | **cmd/server/command_menu_test.go:76** | *(none — see F4)* | **SURVIVED** |
|
||||
| **W2** | **Unregister `sticker` from `factories()` (import removed too)** | **cmd/server/main.go:95** | *(none — see F5)* | **SURVIVED** |
|
||||
| W3 | Corrupt a non-empty expectation (`"newpack": "WRONG"`) — control | command_menu_test.go:75 | `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` | **KILLED** |
|
||||
|
||||
Survivors M5/M6/M7 and W2 were each re-run against the **full** sticker suite / **full repo
|
||||
suite** (`./...`), not just a `-run` subset. All still survived.
|
||||
|
||||
---
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### F1 — HIGH — the `created` reservation-release machinery has zero test coverage
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:106-115` and `:138-165`.
|
||||
|
||||
Both directions of the `created` flag survive mutation:
|
||||
|
||||
- **M5** (never release): survived the full suite.
|
||||
- **M7** (always release, even a resumed reservation): survived the full suite.
|
||||
|
||||
Both branches are reachable. I proved it with two temporary probe tests (since removed):
|
||||
|
||||
- *Release-on-bail is real*: `/newpack newslug` while a pending record for `oldslug`
|
||||
exists and the old set resolves → `reserveSlug` writes `newslug` (`created=true`),
|
||||
`claimSlug`→`resolveStaleIntent` adopts `oldslug` and returns `done=true`, so
|
||||
`releaseSlug(newslug)` must run. Under M5 the probe failed with
|
||||
`newslug still reserved with no pack behind it - name burned`. Reservations are global
|
||||
and permanent, so this is a per-invocation namespace leak.
|
||||
**`TestNewPack_DifferentSlugAdoptsExistingSet` (pack_handlers_test.go:111) walks exactly
|
||||
this path and asserts nothing about `newslug`.** One added line closes it.
|
||||
- *Not-releasing-a-resumed-reservation is real*: reserve `oldslug` for the caller, pending
|
||||
record under a different slug, `getStickerSet` fails unclassifiably → `resolveStaleIntent`
|
||||
bails `done=true`. Under M7 that probe failed: the caller's pre-existing reservation was
|
||||
destroyed, handing the name to the next asker while the set may still exist.
|
||||
|
||||
**`TestNewPack_ResumedReservationSurvivesABail` (pack_handlers_test.go:573) does not test
|
||||
what its name says.** In that test `claimSlug` returns `done=false` (same-slug resume), so
|
||||
the `if created` branch is never reached; the bail happens later in `createOrAdopt`, which
|
||||
never consults `created`. M7 survives it. It is a duplicate of
|
||||
`TestNewPack_UnknownLookupErrorAborts` wearing a different name.
|
||||
|
||||
Fix: assert `newslug` is unreserved in `TestNewPack_DifferentSlugAdoptsExistingSet`, and
|
||||
re-point `TestNewPack_ResumedReservationSurvivesABail` at a bail inside
|
||||
`claimSlug`/`resolveStaleIntent` (a pending record under a *different* slug + a 500 from
|
||||
`getStickerSet` reaches it).
|
||||
|
||||
### F2 — MEDIUM — two `/addsticker` emoji tests pass when no sticker is added at all
|
||||
|
||||
`handlers_test.go:134-159` (`TestAddSticker_EmojiPrecedence`) and `:163-178`
|
||||
(`TestAddSticker_FallsBackToDefaultEmoji`).
|
||||
|
||||
Both use the pattern:
|
||||
|
||||
```go
|
||||
for _, call := range rb.Sent() {
|
||||
if call.Method == "addStickerToSet" && !strings.Contains(call.Form["sticker"], tc.want) {
|
||||
t.Errorf(...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Zero matching calls ⇒ zero assertions ⇒ pass. **M16** confirms it: an early `return` placed
|
||||
before `b.AddStickerToSet` leaves both tests green. They are half-live (M15/M17 kill
|
||||
individual subtests via the emoji value) but they do not guard "a sticker was added".
|
||||
|
||||
`TestAddSticker_HappyPath:108` has the right guard (`countMethod(...) != 1` + `Fatalf`).
|
||||
Add the same two lines to both tests. Same latent shape at `resolve_test.go` — no, those
|
||||
use explicit `countMethod` comparisons and are fine.
|
||||
|
||||
### F3 — MEDIUM — `docs/sticker-packs.md` contradicts the reservation lifecycle it describes
|
||||
|
||||
`docs/sticker-packs.md:99-101`:
|
||||
|
||||
> "A name is claimed only when a pack is actually created, and it is released when that
|
||||
> pack is deleted … A `/newpack` that is refused claims nothing."
|
||||
|
||||
The first clause is false and inverts the module's central safety property. `reserveSlug`
|
||||
writes the reservation **before Telegram is touched** — that write-ahead claim is precisely
|
||||
what makes adoption safe, and `pack.go:236-239` plus `pack_handlers.go:120-137` say so at
|
||||
length. A name is therefore held while a `/newpack` is merely *pending*, and
|
||||
`TestNewPack_UnknownLookupErrorAborts:181` asserts the reservation **must survive** when no
|
||||
pack was created. An interrupted `/newpack` holds its name indefinitely with no pack behind
|
||||
it — the doc tells a reader the opposite.
|
||||
|
||||
The second clause is also too strong: a *classified* refusal releases, but an unknown
|
||||
`getStickerSet`/`createNewStickerSet` failure deliberately keeps both intent and
|
||||
reservation (`pack_handlers.go:325-331, 347-356`).
|
||||
|
||||
Everything else in the doc checks out against source: 512px long edge / 100×100 thumbnail
|
||||
(`image.go:23,25`), 4096px cap (`maxDecodeDimension = 4096`), 2 MB (`maxSourceBytes`),
|
||||
120 stickers, 1–20 emoji, 10-minute confirm TTL, 10-second handler deadline, slug rules,
|
||||
`MODULES` semantics, uniform ownership refusals.
|
||||
|
||||
### F4 — LOW — `command_menu_test` does not verify that a command has an expectation
|
||||
|
||||
`cmd/server/command_menu_test.go:104`: `got != expectedParameters[command.Name]`. A missing
|
||||
map key yields `""`, so any public command with empty `Parameters` that nobody added to the
|
||||
map passes silently. **W1** confirms: deleting `"mypack": ""` changes nothing. Four of the
|
||||
nine new entries (`mypack`, `delsticker`, `setpackicon`, `delpack`) are therefore
|
||||
decorative. The test does not verify what its name ("AllPublicCommandsHaveSafeMetadata")
|
||||
promises for parameterless commands.
|
||||
|
||||
Fix: `want, ok := expectedParameters[command.Name]; if !ok { t.Errorf("no expectation for /%s") }`.
|
||||
That also turns the test into the missing registration guard for F5.
|
||||
|
||||
### F5 — MEDIUM — nothing pins that the sticker module is registered at all
|
||||
|
||||
**W2**: with both the import and `"sticker": sticker.New` removed from `cmd/server/main.go`,
|
||||
`go test ./...` is **fully green** — all 25 packages pass, including
|
||||
`internal/modules/sticker` (its tests construct `state` directly and never go through
|
||||
`factories()`). A bad merge or rebase that drops the factory line ships a bot with none of
|
||||
the nine commands and a green CI.
|
||||
|
||||
`command_menu_test.go` only iterates whatever `reg.PublicCommands()` returns, so an absent
|
||||
module is invisible to it. Cheapest fix is F4's `ok` check, which makes the map an
|
||||
inventory rather than a lookup.
|
||||
|
||||
### F6 — LOW — `/newpack` documented but unregistered `MODULES` default changed silently
|
||||
|
||||
`.env.example` flips `MODULES=` (empty ⇒ load everything) to an explicit 12-module list.
|
||||
The list matches `factories()` exactly (verified key by key), so no module is dropped today.
|
||||
But it is now a hand-maintained duplicate of `factories()` with no test tying the two
|
||||
together — the next module added will be silently excluded for anyone starting from the
|
||||
template. Worth a comment pointing at `factories()`, or a test.
|
||||
|
||||
---
|
||||
|
||||
## 3. Harness review — `internal/testutil/recording_bot.go`
|
||||
|
||||
All four questions checked empirically. **The harness changes are correct and
|
||||
backwards-compatible.**
|
||||
|
||||
- **`FailMethodCode` produces the real sentinels — confirmed.** The library switches on
|
||||
`r.ErrorCode` decoded from the *body* and ignores the HTTP status
|
||||
(`go-telegram/bot@v1.20.0/raw_request.go:102-131`). `FailMethodCode` marshals
|
||||
`{"ok":false,"error_code":…,"description":…}`, so `errors.Is(err, bot.ErrorBadRequest)`
|
||||
holds. `recording_bot_test.go:113` pins this, and `:130` pins the negative contrast for
|
||||
bare `FailMethod`. The doc comment's `raw_request.go:103-125` citation is accurate.
|
||||
- **`StubMethod` / `FailMethod` precedence is correct and tested.** `handle()` checks
|
||||
`shouldFail` before `hasStub` (`:200-209`); `TestRecordingBot_FailureWinsOverStub` covers it.
|
||||
- **`Reset()` is coherent and unchanged for existing callers.** It clears `calls` only —
|
||||
which is exactly what it did before this changeset; the diff only adds a doc comment
|
||||
explaining it. `nextMessageID` is deliberately not reset (IDs stay unique across a
|
||||
Reset), which no caller depends on. All 13 other packages that use the harness call only
|
||||
`Reset()`; **no module outside `sticker` uses `FailMethod`, `FailMethodCode` or
|
||||
`StubMethod`**, so the new precedence rule cannot affect them.
|
||||
- **Message IDs start at 1, not 0.** `handle()` increments *before* assigning
|
||||
(`:192-195`), so the first `sendMessage` returns `message_id: 1`. This matters because
|
||||
production rejects a binding with `action.MessageID == 0` (`delpack_callback.go:137`) —
|
||||
I verified the full round trip (`/delpack` → press the button the handler itself
|
||||
produced → `deleteStickerSet` fires exactly once). No collision. *(Observation only:
|
||||
no test in the suite actually performs that round trip; the pieces are covered
|
||||
separately.)*
|
||||
|
||||
**One real harness concern (MEDIUM):**
|
||||
|
||||
The multipart-parse tolerance (`:180`) is justified — I confirmed `getMe` genuinely fails
|
||||
with `multipart: NextPart: EOF` and `ContentLength=-1`, so the old code made every
|
||||
parameterless method untestable. But the tolerance is **wider than the justification**: I
|
||||
posted a deliberately malformed multipart body (`Content-Type: multipart/form-data;
|
||||
boundary=zzz` with non-multipart content) to `/sendMessage` and the harness answered
|
||||
**HTTP 200** and recorded `{Method:sendMessage Form:map[]}`. A real Telegram would 400 it.
|
||||
|
||||
Impact is bounded — the bot library always builds well-formed multipart, so production
|
||||
cannot realistically emit garbage. The live risk is **masking**: any test asserting a form
|
||||
field is *absent* would falsely pass if the whole form silently failed to parse. Such
|
||||
assertions already exist outside this module, e.g.
|
||||
`internal/modules/stock/dividend_flow_test.go:115,139` (`calls[1].Form["reply_markup"] != ""`).
|
||||
|
||||
Suggested narrowing: tolerate only the empty-body case (`r.ContentLength <= 0`, or the
|
||||
`NextPart: EOF` shape) and keep the 400 for genuinely malformed bodies; or record a
|
||||
`ParseFailed bool` on `SentCall` so a masked parse is visible in `dumpCalls()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Untested error branches
|
||||
|
||||
Prioritised by blast radius if a bug landed there. `✗` = no test reaches the branch.
|
||||
|
||||
**`pack_handlers.go` — state-corrupting or namespace-leaking:**
|
||||
|
||||
- `:111` `if created { releaseSlug }` — ✗ **both directions** (F1). Name-burn / name-theft.
|
||||
- `:178-196` `releaseSlug`'s own cross-user ownership guard (`held.OwnerID != ownerID`) — ✗.
|
||||
Defence-in-depth against a bad call site, with zero coverage; a caller passing the wrong
|
||||
owner would be caught only here.
|
||||
- `:325-331` `createOrAdopt` default branch is covered, but **`:347-356` create failing with
|
||||
an *unclassifiable* error** (intent + reservation must both survive) — ✗. This is the exact
|
||||
mirror of `TestNewPack_UnknownLookupErrorAborts` and is the higher-risk half, since a
|
||||
wrong answer here strands a slug whose set may exist.
|
||||
- `:396-413` `adjustCount`'s `!found` → `storage.ErrNotFound` path, and both handler
|
||||
fallbacks that consume it (`sticker_handlers.go:308-314`, `:352-359`) — ✗. These
|
||||
synthesise a count for the reply; a bug shows the user a wrong number.
|
||||
- `:147-158` `reserveSlug` conflict-but-unreadable (`getErr != nil || !found` → treat as
|
||||
taken) — ✗. Comment calls out that guessing the other way *is* the takeover.
|
||||
- `:220-229` `claimSlug` non-conflict store error / re-read failure — ✗.
|
||||
- `:258-272` `resolveStaleIntent` reservation-read error, and both `Put` failures
|
||||
(`:267`, `:296`) — ✗.
|
||||
- `:283-286` adopt-commit failure — ✗.
|
||||
- `:73-77` `resolver.resolve` (GetMe) failure, `:78-81` `makeSetName` failure through the
|
||||
handler — ✗ (`makeSetName` is unit-tested, the handler branch is not).
|
||||
- `:92-94` pre-check store error, `:495-499` `/mypack` store error, `:531-535` /`renamepack`
|
||||
store error, `:542-547` `/renamepack`'s `isStickerSetMissing` self-heal — ✗.
|
||||
- `:366-369`, `:550-554` commit failures — ✗ (both are best-effort by design).
|
||||
|
||||
**`delpack_callback.go`:**
|
||||
|
||||
- `:144-147` `action.ID != id` → clear button + "replaced by a newer /delpack" — ✗.
|
||||
`TestDelPack_SecondPromptSupersedesTheFirst` is rejected earlier, at the *binding* check
|
||||
(`:137`), so this branch and its `clearButton` side effect never execute in tests.
|
||||
- `:97-100` malformed callback data through the handler — ✗ (`parseDeleteCallback` is
|
||||
unit-tested at `delpack_callback_test.go:84` for the happy case only; no test feeds
|
||||
over-length, non-hex or wrong-prefix data to `handleDelPackCallback`).
|
||||
- `:105-107` `query.From.ID == 0`, `:118-120` `From.ID != action.OwnerID` — ✗ (both
|
||||
unreachable given the owner-keyed lookup; defence in depth).
|
||||
- `:113-116` pending-store read error, `:159-165` both consume-delete failure branches — ✗.
|
||||
- `:32-36` `/delpack` store error and `:37-39` `!found` ("you don't have a pack") — ✗.
|
||||
- `:70-73` `SendMessage` failure — ✗. Note this is the one path in the module that returns
|
||||
a **raw** API error to the dispatcher rather than a `userError`/generic reply.
|
||||
- `:80-83` pending `Put` failure — ✗. Leaves a live button with no server-side action.
|
||||
|
||||
**`resolve.go`:**
|
||||
|
||||
Well covered. Only `:108-111` `getPack` store error is ✗. `resolveSource`'s `replied == nil`
|
||||
refusal (`:51-53`) is ✗ directly, though `resolveOwned`'s equivalent is tested.
|
||||
|
||||
**Whole-feature gap — Phase 5 photo pipeline has no integrated coverage.** Every test
|
||||
message is built by `stickerReply()`, which always sets `Sticker`. Grep confirms no test
|
||||
constructs a `Photo:` / `Document:` reply and feeds it to a handler. Consequently
|
||||
`resolvePhotoSource` (`photo.go:342`) is never executed, and **`handleSetPackIcon`
|
||||
(`setpackicon.go:174`) is reached only by `TestHandlers_RefuseAnonymousSenders`, which
|
||||
returns at the sender check before doing anything** — its download → resize →
|
||||
`SetStickerSetThumbnail` → self-heal body is entirely untested. The pieces (`photoFileID`,
|
||||
`downloadFile`, `toStickerPNG`, `toThumbnailPNG`) are individually well tested; the wiring
|
||||
between them is not. `StubMethod("uploadStickerFile", …)` now makes this testable — that is
|
||||
what the harness change was for.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test isolation, races, lint
|
||||
|
||||
- `go test -race ./internal/modules/... ./internal/testutil/... ./cmd/server/...` — **clean,
|
||||
exit 0, 0 `DATA RACE`**, all 17 packages ok.
|
||||
- `golangci-lint run` on the four changed packages — **0 issues**.
|
||||
- No shared mutable fixtures: every test builds its own `newTestState()` over a fresh
|
||||
`storage.NewMemoryProvider()` and its own `RecordingBot`. No ordering dependence found.
|
||||
- `syncBuffer` (dispatcher_panic_test.go:33) correctly mutex-guards the log sink for the
|
||||
detached-goroutine test, and `waitForLog` polls rather than sleeping. Good.
|
||||
- Two globals are mutated without isolation in `dispatcher_panic_test.go`: `log.SetDefault`
|
||||
(restored via defer) and `metrics` counters (`metrics.Flush()` at :116, never reset).
|
||||
Harmless today — nothing runs in parallel and no other test in `modules_test` asserts on
|
||||
error counters — but the metrics assertion at :130 would become order-dependent if one
|
||||
ever did. Worth a note, not a change.
|
||||
- `seedPack` correctly seeds the slug reservation alongside the pack record
|
||||
(handlers_test.go:45-50), and `seedInterrupted` does the same
|
||||
(pack_handlers_test.go:387-398). Both carry a comment explaining that seeding the record
|
||||
alone builds a state production cannot reach. This is the right instinct and it is why
|
||||
M12b/M22 kill cleanly.
|
||||
|
||||
## 6. Wiring & plan accuracy
|
||||
|
||||
**Registered correctly:** all 9 commands appear in `sticker.go:22-82` with
|
||||
`VisibilityPublic`, descriptions, and `Parameters` matching
|
||||
`docs/command-parameter-conventions.md` (the new `<name...>` "required remaining text" row
|
||||
is a genuine addition, used by `<title...>`). All 9 are menu-described and
|
||||
parameter-documented. README table and `docs/sticker-packs.md` list the same 9.
|
||||
|
||||
**Callback prefix is unique.** `callbackPrefix = "sticker_pack:"` vs the only other
|
||||
callback in the repo, `stock`'s `"stock_div:"`. `registry.go:218-220` enforces this
|
||||
**bidirectionally** (`HasPrefix` both ways), so the check is real, not nominal.
|
||||
|
||||
**Plan accuracy** — `plan.md` status is honestly `partial`, and all 16 unchecked boxes are
|
||||
live-Telegram smoke tests plus the unresolved R11 (does Telegram reserve deleted short
|
||||
names). No inflated completion. Two checked boxes are contradicted by shipped code:
|
||||
|
||||
- `phase-03:245` — "`/newpack` where `GetStickerSet` fails with a non-missing error aborts,
|
||||
**deletes the pending record**, and never calls `CreateNewStickerSet`" is `[x]`, but the
|
||||
shipped code deliberately **keeps** both intent and reservation
|
||||
(`pack_handlers.go:325-331`), and `TestNewPack_UnknownLookupErrorAborts:174-183` asserts
|
||||
that. The file's own superseding note at `phase-03:52-55` says this step is wrong — the
|
||||
checkbox was ticked against the superseded text.
|
||||
- `phase-03:225` — "`sticker.go` factory registering **4 commands** + callback prefix" is
|
||||
`[x]`; the shipped factory registers 9. Stale phase-scoped wording, harmless.
|
||||
|
||||
**`internal/modules/wordle/lookup_test.go`** — confirmed a pure `gofmt` alignment change.
|
||||
The 8 map keys and all 8 values are byte-identical; only leading whitespace inside the
|
||||
composite literal moved (the longest key `" crane "` no longer forces extra padding).
|
||||
No behaviour change.
|
||||
|
||||
## 7. Final state
|
||||
|
||||
Every mutated file restored and verified byte-identical against a pre-review backup:
|
||||
|
||||
```
|
||||
md5sum -c backup/md5.txt → all 31 files OK (no mismatches)
|
||||
diff -r backup/cmdserver cmd/server → cmd/server IDENTICAL
|
||||
diff -r backup/testutil internal/testutil → testutil IDENTICAL
|
||||
diff -r backup/sticker internal/modules/sticker → sticker IDENTICAL
|
||||
diff backup/dispatcher.go internal/modules/dispatcher.go → dispatcher IDENTICAL
|
||||
```
|
||||
|
||||
Three temporary probe test files were created and removed (`zz_probe_test.go`,
|
||||
`zz_probe2_test.go` in `sticker`; `zz_probe_test.go` in `testutil`); none remain.
|
||||
|
||||
`git status --short`:
|
||||
|
||||
```
|
||||
M .env.example
|
||||
M README.md
|
||||
M cmd/server/command_menu_test.go
|
||||
M cmd/server/main.go
|
||||
M docs/command-parameter-conventions.md
|
||||
M go.mod
|
||||
M go.sum
|
||||
M internal/modules/dispatcher.go
|
||||
M internal/modules/wordle/lookup_test.go
|
||||
M internal/testutil/recording_bot.go
|
||||
M internal/testutil/recording_bot_test.go
|
||||
M plans/260824-1051-sticker-pack-module/phase-01-shared-prerequisites.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-02-store-setname-emoji.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-03-pack-lifecycle.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-04-sticker-commands.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-05-photo-pipeline.md
|
||||
M plans/260824-1051-sticker-pack-module/phase-06-wiring-docs.md
|
||||
M plans/260824-1051-sticker-pack-module/plan.md
|
||||
?? docs/sticker-packs.md
|
||||
?? internal/modules/dispatcher_panic_test.go
|
||||
?? internal/modules/sticker/
|
||||
?? plans/reports/correctness-review-260825-1515-sticker-module.md
|
||||
?? plans/reports/security-review-260825-1515-sticker-module.md
|
||||
```
|
||||
|
||||
Identical to the state at review start, plus the two peer reviewers' reports and this file.
|
||||
|
||||
`go test ./...` — **all 25 packages ok**, zero failures.
|
||||
`go test -race` on all module + testutil + cmd/server packages — **ok, 0 data races**.
|
||||
`golangci-lint run` on changed packages — **0 issues**.
|
||||
|
||||
## 8. Recommended actions
|
||||
|
||||
1. **(F1, high)** Assert `newslug` is released in `TestNewPack_DifferentSlugAdoptsExistingSet`;
|
||||
re-point `TestNewPack_ResumedReservationSurvivesABail` at a bail inside
|
||||
`claimSlug`/`resolveStaleIntent` so it kills M7. Two tests, ~6 lines.
|
||||
2. **(F5 + F4, medium)** Add the `want, ok := expectedParameters[name]` presence check in
|
||||
`command_menu_test.go`. Closes both the decorative-entry hole and the missing
|
||||
registration guard in one edit.
|
||||
3. **(F2, medium)** Add the `countMethod(rb, "addStickerToSet") != 1` + `Fatalf` guard to
|
||||
`TestAddSticker_EmojiPrecedence` and `TestAddSticker_FallsBackToDefaultEmoji`.
|
||||
4. **(F3, medium)** Correct `docs/sticker-packs.md:99-101` to describe the write-ahead
|
||||
reservation: the name is claimed *before* Telegram is called and is held while an
|
||||
attempt is pending; only a positively-classified refusal or a confirmed delete releases it.
|
||||
5. **(§3, medium)** Narrow the multipart tolerance to the empty-body case, or surface a
|
||||
`ParseFailed` flag on `SentCall`.
|
||||
6. **(§4, medium)** Add one integrated photo test (`StubMethod("uploadStickerFile", …)`)
|
||||
and one `handleSetPackIcon` happy path — the largest untested surface in the module.
|
||||
7. **(§4, low)** Cover `createOrAdopt`'s unclassifiable-create-error branch and
|
||||
`delpack_callback.go:144` (`action.ID != id`).
|
||||
8. **(§6, low)** Untick or correct `phase-03:245`; fix the "4 commands" wording at `:225`.
|
||||
|
||||
## 9. Unresolved questions
|
||||
|
||||
- `phase-06` leaves R11 (does Telegram permanently reserve a deleted short name?) open, and
|
||||
`dropPackRecord`'s comment reasons about it both ways. Nothing here can settle it without
|
||||
a live token; the code's behaviour is safe under either answer, so it is correctly
|
||||
deferred to the manual smoke list.
|
||||
- Is the `.env.example` switch from empty-`MODULES` to an explicit list intended to change
|
||||
deployed behaviour, or only to document intent? If deployments copy the template, adding
|
||||
a future module will require an `.env` edit that nothing warns about.
|
||||
@@ -1,260 +0,0 @@
|
||||
# Sticker module — round 5 adversarial verification
|
||||
|
||||
Commit `e81b1b7` ("fix(sticker): never adopt an existing pack"), branch
|
||||
`feature/sticker-pack-module`, Go 1.27, golangci-lint v2.13.1.
|
||||
|
||||
**Verdict: DO_NOT_MERGE.** The adoption class is genuinely closed — every route
|
||||
to a committed `Pack` record naming a foreign set was enumerated and executed,
|
||||
and all of them refuse. But the round-5 pattern repeated in the *other*
|
||||
direction: the commit correctly identified that `DeleteStickerSet` is a
|
||||
cross-user primitive keyed by set name, added a guard for one way of reaching
|
||||
it, and left a second way open. A stale `/delpack` confirmation destroys
|
||||
whichever user holds that name at press time.
|
||||
|
||||
---
|
||||
|
||||
## C1 (Critical) — a stale `/delpack` confirmation deletes a re-issued name's pack
|
||||
|
||||
`internal/modules/sticker/delpack_callback.go:177-185`
|
||||
|
||||
```go
|
||||
if current, found, err := getPack(ctx, s.store, action.OwnerID); err != nil {
|
||||
...
|
||||
} else if found && current.Pending && ownsSet(current, action.SetName) {
|
||||
// refuse
|
||||
}
|
||||
// falls through to DeleteStickerSet(action.SetName)
|
||||
```
|
||||
|
||||
The re-check is **negative** — it blocks exactly one bad state (`Pending`) — where
|
||||
it needed to be **positive**: only proceed when the record still authorises this
|
||||
delete. `!found` and "record now names a different set" both fall through to the
|
||||
destructive call. `dropPackRecordIfSet` performs precisely the right check
|
||||
(`found && ownsSet`), but it runs *after* `DeleteStickerSet`, so it protects the
|
||||
local record and not the set.
|
||||
|
||||
### Reproduction (executed; all steps are ordinary public commands)
|
||||
|
||||
Test `TestProbe_StaleConfirmationDeletesReissuedName`, run against
|
||||
`internal/modules/sticker`:
|
||||
|
||||
| # | Actor | Command | Effect |
|
||||
|---|-------|---------|--------|
|
||||
| 1 | U | `/newpack mypack Mine` | committed record + reservation `mypack` |
|
||||
| 2 | U | `/delpack`, **do not press** | `PendingDelete{SetName: mypack_by_testbot}` stored, TTL 10 min |
|
||||
| 3 | U | `/delsticker` down to zero, then any command | set gone at Telegram → `STICKERSET_INVALID` → `dropPackRecord` drops the record **and releases the reservation**. The unpressed confirmation is untouched — no `dropPackRecord` path clears `s.pending`. |
|
||||
| 4 | V | `/newpack mypack Victim Pack` | reservation free, `GetStickerSet` missing → V legitimately creates and owns `mypack_by_testbot` |
|
||||
| 5 | U | presses the button from step 2 | binding OK, not expired, re-check sees `found=false` → **`deleteStickerSet name=mypack_by_testbot`** |
|
||||
|
||||
Observed output:
|
||||
|
||||
```
|
||||
step3 self-heal: U record found=false, reservation held=false
|
||||
step3 U's unpressed confirmation SURVIVED the self-heal
|
||||
step5 V pack found=true {Slug:mypack Name:mypack_by_testbot ... OwnerID:4242 Pending:false}
|
||||
step6 methods = [deleteStickerSet editMessageReplyMarkup sendMessage answerCallbackQuery]
|
||||
HOLE CONFIRMED: U deleted "mypack_by_testbot" — V's pack, created after U's record was gone
|
||||
step6 V record still found=true (local record survives, set does not)
|
||||
step6 answer = "Pack deleted."
|
||||
```
|
||||
|
||||
V is left with a committed record pointing at a destroyed set, and U is told the
|
||||
delete succeeded.
|
||||
|
||||
**Reachability: production, not hypothetical.** Dispatch is serial
|
||||
(`internal/telegram/client.go:28` `WithNotAsyncHandlers`, single worker) — this is
|
||||
a plain sequential command sequence, no race. Steps 1-3 are fully under the
|
||||
attacker's control and take seconds; the only constraint is that step 4 lands
|
||||
inside the 10-minute `pendingDeleteTTL`. The same sequence also occurs with no
|
||||
attacker at all: run `/delpack`, get distracted, empty the pack, run one more
|
||||
command, and whoever takes the freed name loses it when you finally press.
|
||||
|
||||
Second, milder variant, also executed
|
||||
(`TestProbe_StaleConfirmationAfterRecordMovedOn`): with the record moved to a
|
||||
different pack, the press still issues
|
||||
`deleteStickerSet name="mypack_by_testbot"` while the record names
|
||||
`other_by_testbot`. `dropPackRecordIfSet` correctly leaves the record alone —
|
||||
after the set is already gone.
|
||||
|
||||
### Fix shape
|
||||
|
||||
Invert the guard to a positive authorisation, matching `dropPackRecordIfSet`:
|
||||
|
||||
```go
|
||||
current, found, err := getPack(ctx, s.store, action.OwnerID)
|
||||
if err != nil { ...transient answer... }
|
||||
if !found || current.Pending || !ownsSet(current, action.SetName) {
|
||||
s.dropPendingDelete(ctx, key)
|
||||
clearButton(ctx, b, action.ChatID, action.MessageID)
|
||||
return answerCallback(ctx, b, query.ID, "That pack is no longer yours to delete; nothing was deleted at Telegram.")
|
||||
}
|
||||
```
|
||||
|
||||
Additionally, every `dropPackRecord` / `dropPackRecordIfSet` should clear
|
||||
`pendingDeleteKey(ownerID)`. A record that no longer exists must not leave a live
|
||||
capability behind it; the TTL is the only thing bounding it today.
|
||||
|
||||
---
|
||||
|
||||
## H1 (High) — the round-5 re-check is not pinned by any test
|
||||
|
||||
Mutation M2b: delete the entire `getPack` re-check block from
|
||||
`handleDelPackCallback` (delpack_callback.go:177-185).
|
||||
|
||||
**SURVIVED.** Full suite `ok`. Item 4 of the commit description — "a defensive
|
||||
re-check was also added in `handleDelPackCallback` under the lock" — has zero
|
||||
test coverage. This is the same defect class the memory file records: a claim
|
||||
asserted in the commit text that no test exercises. It is also the exact guard
|
||||
whose incompleteness produces C1, so the gap and the bug are the same omission.
|
||||
|
||||
---
|
||||
|
||||
## Mutation results (full)
|
||||
|
||||
Backed up to scratchpad, restored, `md5sum -c` all match, `git status --short`
|
||||
empty (verified after every mutation).
|
||||
|
||||
| # | Mutation | Result | Killing test |
|
||||
|---|----------|--------|--------------|
|
||||
| M1 | `createPack` `err == nil` → `finishNewPack` (adoption restored) | **killed** | `TestNewPack_InterruptedAttemptWithLiveSetIsRefused`, `TestNewPack_WipedStoreCannotAdoptSurvivingPack`, `TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver` |
|
||||
| M2 | remove `/delpack` pending short-circuit | **killed** | `TestDelPack_PendingRecordDeletesNothingAtTelegram` |
|
||||
| **M2b** | **remove `handleDelPackCallback`'s under-lock re-check** | **SURVIVED** | — |
|
||||
| M3 | neutralise `releaseSlug` ownership check | **killed** | `TestReleaseSlug_RefusesANameHeldBySomeoneElse` |
|
||||
| M4 | re-attach `releaseSlug`'s ownership read to the request ctx | **killed** | `TestReleaseSlug_CompletesOnACancelledContext` |
|
||||
| M5b | `resolveStaleIntent` `err == nil` branch adopts the old set (R4's second path) | **killed** | `TestNewPack_DifferentSlugDoesNotAdoptExistingSet` |
|
||||
| M6 | release the slug unconditionally on claim bail (drop the `created` guard) | **killed** | `TestNewPack_ResumedReservationNotReleasedWhenClaimBails` |
|
||||
| **M7** | **`resolveStaleIntent` missing-branch no longer releases the dead name** | **SURVIVED** | — |
|
||||
| M8 | `createPack` unknown-lookup branch drops intent + reservation | **killed** | `TestNewPack_UnknownLookupErrorAborts` |
|
||||
| M9 | `createPack` refusal keeps the intent | **killed** | `TestNewPack_InterruptedAttemptWithLiveSetIsRefused` |
|
||||
|
||||
M7 detail: `TestNewPack_DifferentSlugReplacesDeadIntent` (pack_handlers_test.go:149)
|
||||
is named for replacing a dead intent but asserts only the *new* pack. Nothing
|
||||
checks that `oldslug`'s reservation was freed, so the R1 name-burn class is
|
||||
unpinned. The code is correct today; only the regression barrier is missing.
|
||||
|
||||
The four tests added by this commit are otherwise non-vacuous: M1 kills
|
||||
`TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver`, M2 kills
|
||||
`TestDelPack_PendingRecordDeletesNothingAtTelegram`, M3/M4 kill the two
|
||||
`TestReleaseSlug_*` tests. The `ctxHonouringSlugs` wrapper is load-bearing — its
|
||||
comment ("this assertion passed whether or not the code detached until the store
|
||||
was made to honour cancellation") is accurate.
|
||||
|
||||
---
|
||||
|
||||
## What the commit did close (verified by execution, not inspection)
|
||||
|
||||
### No path to a committed record naming a foreign set
|
||||
|
||||
`Pending = false` is written in exactly one place, `finishNewPack`
|
||||
(pack_handlers.go:352), reachable only after `CreateNewStickerSet` returns nil.
|
||||
`adjustCount` and `handleRenamePack` preserve/require the flag. Enumerated and
|
||||
executed in `TestProbe_PostWipeAttackSurface`, `TestProbe_PendingToCommittedSweep`,
|
||||
`TestProbe_CommittedRecordOnlyAfterCreate`:
|
||||
|
||||
- post-wipe `/newpack victimslug` with the set live → `slugTaken`, record dropped,
|
||||
reservation released, no `createNewStickerSet`
|
||||
- inconclusive probe, then a second `/newpack` with the set live → refused, cleaned
|
||||
- resume with a pending record naming the victim's set → refused, cleaned
|
||||
- `createNewStickerSet` refused (`PACK_SHORT_NAME_OCCUPIED`) → intent and
|
||||
reservation both released
|
||||
- with a pending record naming the victim's set, all six other commands refuse:
|
||||
`/addsticker` and `/renamepack` → `noPackYet`; `/delsticker`, `/editsticker`,
|
||||
`/ordersticker`, `/setpackicon` → `notOwnedRefusal`. **Zero** Telegram
|
||||
mutations in every case.
|
||||
|
||||
### `/delpack` pending short-circuit cannot be aimed at another user
|
||||
|
||||
`getPack(ctx, s.store, senderID(msg))` and `dropPackRecord(ctx, ownerID)` are
|
||||
owner-keyed throughout, and `releaseSlug` re-verifies the holder itself
|
||||
(M3 confirms). Executed: a caller can only free a reservation they hold
|
||||
(`sticker_release_slug_refused` logged otherwise). Freeing a name that still has
|
||||
a live set behind it is possible but not exploitable — the next claimant is
|
||||
refused by `createPack`'s occupancy probe (verified: third party gets
|
||||
`slugTaken`).
|
||||
|
||||
### No wedge
|
||||
|
||||
`TestProbe_WedgeAudit`, 4 leftover states × 3 escape routes = 12 runs. Every
|
||||
state escapes via `/newpack <other-slug>`; 11 of 12 also escape via the same
|
||||
slug or `/delpack`. The one refusal (`pending record + foreign reservation`,
|
||||
same slug) is correct and has two working escapes.
|
||||
|
||||
---
|
||||
|
||||
## M (Medium) — resuming an interrupted `/newpack` silently uses the old title
|
||||
|
||||
`claimSlug`'s resume branch (pack_handlers.go:246) returns `existing`, discarding
|
||||
the freshly parsed `title`. Executed (`TestProbe_ResumeIgnoresNewTitle`): after a
|
||||
pending `mypack`/"Old" record, `/newpack mypack Brand New Title` calls
|
||||
`createNewStickerSet title="Old"` and answers `"Created Old."` — confirming a
|
||||
title the user did not type, with `/renamepack` the only fix. The same branch
|
||||
also reuses `existing.Name`, so after a BotFather rename every resume builds a
|
||||
set name with the stale `_by_<old>` suffix that Telegram will reject; the
|
||||
freshly computed `setName` is discarded.
|
||||
|
||||
## L1 — `resolveStaleIntent` leaks the old reservation permanently
|
||||
|
||||
The `err == nil` branch keeps the old reservation with no record pointing at it
|
||||
(`TestProbe_StaleIntentReservationLeak`: `reservation[oldslug] owner=42`, no pack
|
||||
record). Correct in intent — a set really occupies the name — but the entry is
|
||||
unreachable by any code path while the user holds another committed pack. Not
|
||||
weaponisable: reaching the branch requires a set to genuinely exist under the
|
||||
name, so it is not a cheap name-burn primitive.
|
||||
|
||||
## L2 — confirmed delete leaks the reservation when the record moved on
|
||||
|
||||
`TestProbe_ConfirmedDeleteLeaksReservationWhenRecordMoved`: `DeleteStickerSet`
|
||||
succeeds, `dropPackRecordIfSet` correctly declines to touch the moved record, and
|
||||
nobody releases `mypack` — a name whose set is now definitely gone stays reserved
|
||||
forever. Same code path as C1's second variant.
|
||||
|
||||
## L3 — `releaseSlug` read-then-delete is not atomic
|
||||
|
||||
`getSlugReservation` then `Delete` on the same key with no CAS. Under concurrent
|
||||
dispatch, a reservation re-claimed between the two calls would be deleted by the
|
||||
previous holder. **Not reachable today** (serial dispatch), but `state.go:83`
|
||||
documents the lock as "load-bearing rather than decorative" because of the cron
|
||||
scheduler and stats hook — worth a `PutVersioned`-style compare-and-delete or an
|
||||
explicit note that neither touches this store.
|
||||
|
||||
---
|
||||
|
||||
## Gates
|
||||
|
||||
| Gate | Result |
|
||||
|------|--------|
|
||||
| `go build ./...` | pass |
|
||||
| `go vet ./...` | pass |
|
||||
| `gofmt -l .` | clean |
|
||||
| `golangci-lint run ./...` | 0 issues |
|
||||
| `go test ./...` | pass |
|
||||
| `go test -race ./...` | pass |
|
||||
| `go test -race -count=20 ./internal/modules/sticker/...` | pass, 86.5s, no flakes |
|
||||
| workspace restored | `md5sum -c` all match, `git status --short` empty |
|
||||
|
||||
## Recommended actions
|
||||
|
||||
1. **Blocking** — fix C1: invert the `handleDelPackCallback` re-check to positive
|
||||
authorisation (`!found || Pending || !ownsSet` → refuse).
|
||||
2. **Blocking** — clear `pendingDeleteKey(ownerID)` in `dropPackRecord` and
|
||||
`dropPackRecordIfSet`, so a dropped record cannot leave a live delete
|
||||
capability behind.
|
||||
3. **Blocking** — add a test for the re-check (kill M2b) covering all three bad
|
||||
states: `!found`, `Pending`, and record-names-another-set. The end-to-end
|
||||
sequence in C1 is the right shape.
|
||||
4. High — extend `TestNewPack_DifferentSlugReplacesDeadIntent` to assert the old
|
||||
reservation was released (kill M7).
|
||||
5. Medium — carry the new title (and freshly computed set name) through the
|
||||
resume branch, or state in the reply that the original title was kept.
|
||||
6. Low — release the reservation in L2's branch; document or close L3.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Does Telegram in fact delete a sticker set when its last sticker is removed?
|
||||
The module documents this as undocumented behaviour. C1's step 3 uses it as the
|
||||
cheapest self-service way to make the set vanish, but the hole does not depend
|
||||
on it — any external deletion, or any `STICKERSET_INVALID` self-heal, reaches
|
||||
the same state.
|
||||
- Is `pendingDeleteTTL` (10 min) intended as a security bound? It is currently
|
||||
the only thing limiting C1's exploitation window, and the comment justifies it
|
||||
on irreversibility grounds rather than as an authorisation control.
|
||||
@@ -1,310 +0,0 @@
|
||||
# Adversarial verification — sticker module, round 6
|
||||
|
||||
- Commit under review: `b7803ce` ("fix(sticker): prove authority before a confirmed pack delete")
|
||||
- Branch: `feature/sticker-pack-module`, Go 1.27, golangci-lint v2.13.1
|
||||
- Method: static enumeration + driven end-to-end probes + mutation testing.
|
||||
All source mutations were backed up, restored, and verified byte-identical
|
||||
(`git status --short` empty, md5sums match baseline).
|
||||
|
||||
## Verdict
|
||||
|
||||
**The security fix is correct.** I could not reach any of the seven
|
||||
owner-unscoped Telegram mutations with authority the caller does not hold,
|
||||
under serial dispatch or under forced concurrency. The R5 hole
|
||||
(`DeleteStickerSet` via a stale confirmation) is closed twice over, and I
|
||||
confirmed by driven probe that the allowlist alone still holds in the one
|
||||
production state where change 2 fails.
|
||||
|
||||
**The recurring pattern did repeat, one level down.** The flagship new test is
|
||||
vacuous with respect to the guard it is named for, and the two disjuncts of the
|
||||
new allowlist that the commit message itself identifies as the R5 bug are
|
||||
completely unpinned. Nothing in the suite stops this fix from regressing back
|
||||
into exactly the blocklist it replaced.
|
||||
|
||||
That is a test-integrity defect, not a live exploit. See "Merge position".
|
||||
|
||||
---
|
||||
|
||||
## 1. Enumeration of every owner-unscoped Telegram mutation
|
||||
|
||||
`grep` over `internal/modules/sticker/*.go` (non-test) yields exactly these
|
||||
mutating calls. For each: what proves ownership at the moment of the call, and
|
||||
whether that proof can go stale or be manufactured.
|
||||
|
||||
| Call | Site | Proof of authority at call time | Can it go stale / be forged? |
|
||||
|---|---|---|---|
|
||||
| `DeleteStickerSet` | `delpack_callback.go:210` | Under `lockUser`, immediately before the call: `found && !current.Pending && ownsSet(current, action.SetName)` re-read from the store | **No.** Non-`Pending` records are written only by `finishNewPack` (after a successful `CreateNewStickerSet`) and by `commitPack` from `adjustCount`/`handleRenamePack`, both of which copy an existing record's `Name`. So a non-`Pending` record proves this owner created that set. Gap between check and call is one store `Delete` on the pending key, no dispatch point. |
|
||||
| `CreateNewStickerSet` | `pack_handlers.go:370` | `GetStickerSet(pack.Name)` must positively return `STICKERSET_INVALID` in the same handler | No adoption path remains; `err == nil` (occupied) drops the intent and releases the reservation. Verified by probe C below. |
|
||||
| `SetStickerSetTitle` | `pack_handlers.go:591` | `getPack(ownerID)` found and `!Pending`; `Name` taken from that record | Owner-keyed read, same handler. Read happens *before* `lockUser` — hypothetical-concurrency only (see L2). |
|
||||
| `SetStickerSetThumbnail` | `setpackicon.go:44` | `resolveOwned` → `ownsSet(pack, replied.Sticker.SetName)`; `Name` from the caller's own record | Slow media leg sits between check and call, but no dispatch point under serial dispatch. |
|
||||
| `AddStickerToSet` | `sticker_handlers.go:69` | `getPack(ownerID)` found and `!Pending`; `UserID` is always the caller, `Name` from the caller's record | Same shape. |
|
||||
| `DeleteStickerFromSet` | `sticker_handlers.go:122` | `resolveOwned` → `ownsSet(pack, st.SetName)` on the replied sticker | `st.SetName` and `st.FileID` come from the same Telegram-rendered `Sticker`; not client-forgeable. Old scrollback stickers from a deleted-then-reclaimed pack are stopped because the record is dropped alongside the set. |
|
||||
| `SetStickerEmojiList` | `sticker_handlers.go:175` | `resolveOwned` | Same. |
|
||||
| `SetStickerPositionInSet` | `sticker_handlers.go:214` | `resolveOwned` | Same. |
|
||||
|
||||
**No deferred capability exists anywhere except `PendingDelete`.** Every other
|
||||
command resolves authority and consumes it inside the same handler invocation,
|
||||
so the stale-authority shape found at `/delpack` has no sibling at
|
||||
`/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`
|
||||
or `/renamepack`. I drove `/addsticker` and `/delsticker` end to end against a
|
||||
record that had moved on; both refuse at `resolveOwned`/`getPack`.
|
||||
|
||||
## 2. Attacks driven end to end (probe results)
|
||||
|
||||
Probes were written as a temporary test file, run, and removed.
|
||||
|
||||
| Probe | Setup | Result |
|
||||
|---|---|---|
|
||||
| **A** — record gone, confirmation alive | non-`Pending` pack + live `PendingDelete`, record deleted out from under it, victim seeded holding `mypack_by_testbot` | `methods = [editMessageReplyMarkup answerCallbackQuery]`. **0 `deleteStickerSet`.** Victim record intact. Allowlist holds. |
|
||||
| **A2** — record moved to `Pending`, confirmation alive | same, record replaced with a `Pending` intent naming the same set | **0 `deleteStickerSet`.** |
|
||||
| **C** — `/delpack` on a `Pending` record frees a name with a live set behind it, next claimant attacks | Bob's interrupted attempt created the set; Bob `/delpack` (frees `mypack`); Alice `/newpack mypack` | Alice: `[getMe getStickerSet sendMessage]`, reply `"That pack name is taken."` No record, no adoption. Alice's follow-up `/delpack`: `"You don't have a pack yet."`, 0 API calls. **`createPack`'s occupancy probe is the wall and it holds.** |
|
||||
| **E** — `dropPackRecord` with a failing pending store | `pending.Delete` returns an error | Record deleted, reservation released, **confirmation survives**. This is the state that makes the allowlist's `!found` disjunct load-bearing in production. |
|
||||
| **G** — can a `Pending` record coexist with a live confirmation via handlers alone? | `/delpack` (prompt live) then `/newpack second Two` | Refused at the precheck: `"You already have a pack (mypack)."` Not reachable through handlers — but *is* reachable after an E-style failed clear. |
|
||||
| **Concurrency** — 3 goroutines (`handleDelPackCallback` + `handleDelPack` + `handleAddSticker`) × 50 iterations × 3 runs, `-race` | | No races, never more than one `deleteStickerSet`. |
|
||||
|
||||
Dispatch model re-confirmed serial: `internal/telegram/client.go:27-28`
|
||||
(`WithSkipGetMe`, `WithNotAsyncHandlers`), no `WithWorkers` anywhere, so the
|
||||
library default of one worker applies. All concurrency observations below are
|
||||
labelled hypothetical.
|
||||
|
||||
## 3. Mutation testing
|
||||
|
||||
Backup → mutate → `go test ./internal/modules/sticker/` → restore.
|
||||
|
||||
| # | Mutation | Outcome | Killing test |
|
||||
|---|---|---|---|
|
||||
| 1 | Allowlist reverted to the R5 blocklist (`found && current.Pending && ownsSet(...)`) | **KILLED** | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` (`delpack_callback_test.go:286`) — *only* this one |
|
||||
| 2 | `dropPendingDelete` removed from `dropPackRecord` | **KILLED** | `TestDropPackRecord_ClearsAnOutstandingConfirmation` (`pack_handlers_test.go:874`) — *only* this one |
|
||||
| 3 | Resume returns `existing` verbatim (both carry-overs removed) | **KILLED** | `TestNewPack_ResumeUsesTheTitleJustTyped` |
|
||||
| 3b | Only `resumed.Name = intent.Name` removed | **SURVIVED** | — |
|
||||
| 4 | `releaseSlug` removed from `resolveStaleIntent`'s `isStickerSetMissing` branch | **KILLED** | `TestNewPack_DifferentSlugReplacesDeadIntent` (`pack_handlers_test.go:171`) |
|
||||
| 5 | Allowlist disjunct `!found` dropped (`_ = found`) | **SURVIVED** | — |
|
||||
| 6 | Allowlist disjunct `current.Pending` dropped | **SURVIVED** | — |
|
||||
| 7 | Allowlist disjunct `!ownsSet(current, action.SetName)` dropped | **KILLED** | `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` |
|
||||
| 8 | Mutations 1 **and** 2 together | **KILLED** | all three of `StalePressLeavesTheCurrentPackAlone`, `StalePressCannotDeleteTheNextHolder`, `DropPackRecord_ClearsAnOutstandingConfirmation` |
|
||||
|
||||
Gates: `go build ./...` OK · `go test ./... -race -count=20` OK ·
|
||||
`golangci-lint run ./...` → `0 issues.` · `gofmt -l .` → clean.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### H1 — The flagship round-6 test does not exercise the round-6 guard (High, test integrity)
|
||||
|
||||
`TestDelPackCallback_StalePressCannotDeleteTheNextHolder`
|
||||
(`internal/modules/sticker/delpack_callback_test.go:411-453`) is documented as
|
||||
the regression test for the allowlist. It is not.
|
||||
|
||||
Reproduction (mutation 1, in isolation):
|
||||
|
||||
```
|
||||
$ # revert the allowlist to the R5 blocklist, nothing else
|
||||
$ go test ./internal/modules/sticker/ -run TestDelPackCallback_StalePressCannotDeleteTheNextHolder -v
|
||||
--- PASS: TestDelPackCallback_StalePressCannotDeleteTheNextHolder (0.00s)
|
||||
```
|
||||
|
||||
Cause: at line 434 the test calls
|
||||
|
||||
```go
|
||||
// U's set vanishes at Telegram; a self-heal clears the record and the name.
|
||||
s.dropPackRecord(ctx, testUser)
|
||||
```
|
||||
|
||||
`dropPackRecord` now (change 2) deletes the `PendingDelete` as well, so the
|
||||
callback returns at `delpack_callback.go:124`
|
||||
(`pending.Get` → `storage.ErrNotFound` → `"This confirmation expired or was
|
||||
already used."`) roughly fifty lines before the allowlist at line 193. The test
|
||||
proves change 2, and only change 2 — which is already proven by
|
||||
`TestDropPackRecord_ClearsAnOutstandingConfirmation`.
|
||||
|
||||
Mutation 2 in isolation also leaves this test **passing** (the allowlist then
|
||||
catches it). Only the double revert (mutation 8) fails it. A test that requires
|
||||
both defences to be removed before it fires cannot detect either one
|
||||
regressing.
|
||||
|
||||
This is the fourth consecutive round in which a test was named for a behaviour
|
||||
a structurally earlier guard prevents it from reaching.
|
||||
|
||||
**Fix:** seed the state directly instead of routing through `dropPackRecord` —
|
||||
`s.store.Delete(ctx, packKey(testUser))` and leave the confirmation in place —
|
||||
so the press actually arrives at the under-lock re-check. Probe A above is a
|
||||
working version of that test; it passes on `HEAD` and fails under mutation 1.
|
||||
|
||||
### H2 — The `!found` disjunct is unpinned, and the state it guards is production-reachable (High)
|
||||
|
||||
Mutation 5 (`_ = found; if current.Pending || !ownsSet(current, action.SetName)`)
|
||||
survives the entire suite. That disjunct is the exact half of the R5 bug the
|
||||
commit message calls out first ("fell through on the two that mattered: no
|
||||
record at all").
|
||||
|
||||
It is not dead code. `dropPackRecord` logs and continues when the pending
|
||||
delete cannot be removed:
|
||||
|
||||
```go
|
||||
func (s *state) dropPendingDelete(ctx context.Context, key string) {
|
||||
commitCtx, cancel := commitContext(ctx)
|
||||
defer cancel()
|
||||
if err := s.pending.Delete(commitCtx, key); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
log.Error("sticker_drop_pending_delete", "err", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Probe E confirms the resulting state on `HEAD`: pack record gone, reservation
|
||||
released, confirmation still live and pressable. Probe A confirms `!found` is
|
||||
what refuses the press in that state, and that without it the press lands on
|
||||
whoever holds the name now. A single Mongo write failure is enough to enter it.
|
||||
|
||||
**Fix:** add the probe-A test (record deleted directly, confirmation left
|
||||
alive, victim seeded under the same name, assert zero `deleteStickerSet`).
|
||||
|
||||
### H3 — The `current.Pending` disjunct is unpinned (Medium-High)
|
||||
|
||||
Mutation 6 survives. Reachable in production by composing H2 with a normal
|
||||
`/newpack`: once a failed `dropPendingDelete` has left a confirmation alive
|
||||
with no record, `/newpack` passes the precheck and writes a fresh `Pending`
|
||||
intent. A `Pending` record is bookkeeping written *before* Telegram is called —
|
||||
`handleDelPack` and `TestDelPack_PendingRecordDeletesNothingAtTelegram` both
|
||||
say so explicitly — so it must never authorise a delete. Probe A2 shows the
|
||||
guard works today; nothing pins it.
|
||||
|
||||
**Fix:** add probe A2 as a test.
|
||||
|
||||
### M1 — `resumed.Name = intent.Name` is unpinned and repoints the record at a different set under a bot rename (Medium)
|
||||
|
||||
`pack_handlers.go`, `claimSlug` resume branch:
|
||||
|
||||
```go
|
||||
resumed := existing
|
||||
resumed.Title = intent.Title
|
||||
resumed.Name = intent.Name
|
||||
return resumed, false, nil
|
||||
```
|
||||
|
||||
Mutation 3b (removing only the `Name` line) survives the whole suite — the
|
||||
title carry-over is the only half the new test covers, despite the `Name` line
|
||||
being the only one of the two that changes *which set* is touched.
|
||||
|
||||
Probe B, driven end to end: seed an interrupted attempt with
|
||||
`Name = "mypack_by_oldbot"` (bot renamed in BotFather since), stub `getMe` →
|
||||
`testbot`, re-run `/newpack mypack Title`:
|
||||
|
||||
```
|
||||
probed name = "mypack_by_testbot"
|
||||
created name = "mypack_by_testbot"
|
||||
stored pack = {Slug:mypack Name:mypack_by_testbot ... Pending:false}
|
||||
```
|
||||
|
||||
Before `b7803ce` the probe targeted `mypack_by_oldbot`. If the interrupted
|
||||
attempt did create that set, the old behaviour answered `slugTaken` and cleaned
|
||||
up; the new behaviour creates a second set and orphans the first with no local
|
||||
record pointing at it and no route to reach it through the bot. The `mypack`
|
||||
reservation stays held (same slug), so no cross-user damage — this is a
|
||||
resource leak and a behaviour regression, not a security defect.
|
||||
|
||||
It also directly contradicts the invariant `ownsSet`'s own doc comment states
|
||||
(`setname.go:73-79`): "It deliberately does not re-derive the name from the
|
||||
live bot username. Renaming the bot in BotFather is supported and leaves
|
||||
existing set names untouched." The resume branch now re-derives it.
|
||||
|
||||
**Fix:** either drop the `Name` carry-over (the title fix is what the commit
|
||||
message describes; the `Name` line is unexplained scope), or keep it and add a
|
||||
test that pins the intent under a changed username. As written it is an
|
||||
unexplained, untested line inside a security-sensitive commit.
|
||||
|
||||
### L1 — `ownsSet` uses Unicode case folding on a security comparison (Low, informational)
|
||||
|
||||
`strings.EqualFold` applies simple Unicode folding, so
|
||||
`ownsSet(Pack{Name: "mypack_by_testbot"}, "mypacKk_by_testbot")` (U+212A
|
||||
KELVIN SIGN) returns **true** — verified by probe F. Not exploitable: Telegram
|
||||
constrains sticker-set short names to `[A-Za-z0-9_]`, `validateSlug` forces
|
||||
`^[a-z][a-z0-9_]{2,39}$`, and the only two inputs are a stored record name and
|
||||
a Telegram-rendered `Sticker.SetName`. Recording it because the comment
|
||||
justifies `EqualFold` on casing grounds alone and does not note the folding
|
||||
surface it brings along. `strings.ToLower` comparison would be equally correct
|
||||
and narrower.
|
||||
|
||||
### L2 — Read-modify-write outside the lock in four handlers (Low, hypothetical concurrency)
|
||||
|
||||
`state.go`'s corrected `lockUser` comment says the lock "stays because every
|
||||
mutation here is a read-modify-write, which is wrong the moment dispatch stops
|
||||
being serial." Four handlers do not honour that:
|
||||
|
||||
- `handleDelPack` — reads the pack, then writes `s.pending`, with **no lock at
|
||||
all** on the prompt path (the lock is taken only inside the `pack.Pending`
|
||||
branch).
|
||||
- `handleAddSticker`, `handleDelSticker`, `handleRenamePack` — `getPack` runs
|
||||
*before* `defer s.lockUser(ownerID)()`, so the record they act on was read
|
||||
outside the critical section.
|
||||
|
||||
Only `handleNewPack` takes the lock first. Moot under
|
||||
`WithNotAsyncHandlers` + one worker; flagged because the comment asserts a
|
||||
property the code does not have, which is precisely the class of defect change
|
||||
6 was written to fix.
|
||||
|
||||
### L3 — `internal/keylock` package doc contradicts the dispatch model (Low, out of scope)
|
||||
|
||||
`internal/keylock/keylock.go:6-8`: "The bot dispatcher runs each Telegram update
|
||||
in its own goroutine". `internal/telegram/client.go:18-22` and
|
||||
`internal/modules/dispatcher.go:124-126` both say the opposite, and change 6
|
||||
corrected `state.go` to match. Same wrong-reason-for-a-right-guard shape, one
|
||||
package over. Not this commit's responsibility; worth a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Previously closed classes — re-confirmed still closed
|
||||
|
||||
| Class | Evidence |
|
||||
|---|---|
|
||||
| Post-wipe adoption | No adoption branch remains (`createPack` has only `occupied → refuse` / `missing → create` / `unknown → abort`). `TestNewPack_WipedStoreCannotAdoptSurvivingPack` passes; mutation of the occupancy branch is out of scope but the branch is asserted on directly. |
|
||||
| Inconclusive probe then live set | `TestNewPack_InconclusiveProbeThenLiveSetCannotTakeOver` passes; the guard it defeated no longer exists (refusal is unconditional). |
|
||||
| Pending record as delete authority | `handleDelPack` refuses to prompt for a `Pending` record and drops it locally; `TestDelPack_PendingRecordDeletesNothingAtTelegram` passes; the callback's `current.Pending` disjunct is a second wall (probe A2). |
|
||||
| Name-burning DoS | Precheck ordering (record read before `reserveSlug`) intact; `TestNewPack_RefusedRunsClaimNoNames` and `TestNewPack_FreshReservationReleasedWhenClaimBails` cover it. |
|
||||
| Cross-user `releaseSlug` | Ownership verified inside the operation, not the caller; `TestReleaseSlug_RefusesANameHeldBySomeoneElse` passes. |
|
||||
|
||||
## Change 2 audit (`dropPackRecord` now writes `s.pending`)
|
||||
|
||||
Every call site passes an owner the caller already owns — no cross-user aim is
|
||||
possible:
|
||||
|
||||
| Call site | `ownerID` source |
|
||||
|---|---|
|
||||
| `handleDelPack` (pending branch) | `senderID(msg)` |
|
||||
| `handleRenamePack` (`isStickerSetMissing`) | `senderID(msg)` |
|
||||
| `handleAddSticker` / `handleDelSticker` / `handleEditSticker` / `handleOrderSticker` / `handleSetPackIcon` (`isStickerSetMissing`) | `senderID(msg)` |
|
||||
| `dropPackRecordIfSet` ← delpack callback success | `action.OwnerID`, and the callback already proved `query.From.ID == action.OwnerID` and loaded the action under the presser's own key |
|
||||
|
||||
`senderID` additionally rejects bots, anonymous group admins
|
||||
(`SenderChat != nil`) and `From.ID == 0`, so `pendingDeleteKey` can never be
|
||||
built from the shared `GroupAnonymousBot` identity. Failure of the added write
|
||||
is logged and non-fatal, leaving the record deleted and the confirmation alive
|
||||
— the H2 state, which the allowlist covers.
|
||||
|
||||
## Merge position
|
||||
|
||||
`b7803ce` is a genuine, correct security fix and I would not block it on
|
||||
correctness. What I do block on is the test claim: the commit ships a test
|
||||
named for the guard it introduces, that guard can be fully reverted with the
|
||||
test still green, and two of the guard's three load-bearing disjuncts have zero
|
||||
coverage. Given five prior rounds where a false-clean was produced by exactly
|
||||
this — a same-named test that never reaches the branch — the fix should not
|
||||
land with its own regression detector inoperative.
|
||||
|
||||
Blocking work is small and mechanical: replace `s.dropPackRecord(ctx, testUser)`
|
||||
in `StalePressCannotDeleteTheNextHolder` with a direct `s.store.Delete`, and add
|
||||
the probe-A2 variant. Both are ten-line changes and both fail on `HEAD` under
|
||||
the corresponding mutation.
|
||||
|
||||
M1 (`resumed.Name`) should be resolved before merge too — decided either way,
|
||||
but not left as an untested, undescribed line in a commit about proving
|
||||
authority.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Is `resumed.Name = intent.Name` intentional, and if so what should happen to
|
||||
a set stranded under the pre-rename name? The commit message describes only
|
||||
the title fix.
|
||||
2. Does Telegram reserve a deleted sticker set's short name? Plan note R11
|
||||
still marks this unverified, and `dropPackRecord`'s release-the-name
|
||||
behaviour is documented as a no-op if it does. Unchanged by this commit.
|
||||
@@ -1,173 +0,0 @@
|
||||
# Sticker module — round 7 scoped verification
|
||||
|
||||
- Branch `feature/sticker-pack-module`, HEAD `5e4fb0f`, Go 1.27, golangci-lint v2.13.1.
|
||||
- Scope: only H1, H2/H3, M1 from `verify-260825-1700-sticker-round6.md`, plus a scan of `5e4fb0f` for anything new. The 16-call Telegram enumeration was NOT redone.
|
||||
- All mutations were applied to working-tree copies, then restored from backup. Final state: `git status --short` empty, 27/27 md5sums match, `git diff HEAD --stat` empty.
|
||||
|
||||
## Verdict
|
||||
|
||||
**SAFE_TO_MERGE.** H1, H2/H3 and M1 are closed. The `!found` equivalent-mutant claim is correct and is itself test-pinned. Nothing unintended was found in the commit.
|
||||
|
||||
## Mutation results
|
||||
|
||||
Guard under test, `internal/modules/sticker/delpack_callback.go:197`:
|
||||
|
||||
```go
|
||||
if !found || current.Pending || !ownsSet(current, action.SetName) {
|
||||
```
|
||||
|
||||
| # | Mutation | Result | Killing test |
|
||||
|---|---|---|---|
|
||||
| M1 | drop `!found` (`_ = found` added to compile) | **SURVIVED** | none — equivalent mutant, see below |
|
||||
| M2 | drop `current.Pending` | **KILLED** | `TestDelPackCallback_StaleAuthorityNeverReachesTelegram/record_is_unconfirmed` |
|
||||
| M3 | drop `!ownsSet(...)` | **KILLED** | `.../record_moved_on` **and** `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` |
|
||||
| M4 | full revert to the round-5 blocklist (`if found && current.Pending && ownsSet(...)`) | **KILLED** | `.../record_gone`, `.../record_moved_on`, `TestDelPackCallback_StalePressLeavesTheCurrentPackAlone` |
|
||||
| M5 | re-add `resumed.Name = intent.Name` (`pack_handlers.go`) | **KILLED** | `TestNewPack_ResumeKeepsTheStoredSetName` (`set name = "mypack_by_testbot", want the stored "mypack_by_oldbot"`) |
|
||||
| M6 | drop `resumed.Title = intent.Title` (control, prior round's fix) | **KILLED** | `TestNewPack_ResumeUsesTheTitleJustTyped` (3 assertions) |
|
||||
| M7 | drop `s.dropPendingDelete(ctx, key)` from the guard body | **SURVIVED** | none — cleanup, not authority; see Informational |
|
||||
|
||||
### H1 — closed
|
||||
|
||||
The replacement test is not vacuous. M4 (full guard revert) fails two of the three table
|
||||
cases; every case therefore executes past `pending.Get` and reaches the under-lock
|
||||
allowlist, which is exactly what the round-6 test did not do. Non-vacuity is further
|
||||
proven by the *specificity* of M2 and M3: `record_is_unconfirmed` fails only when
|
||||
`current.Pending` is removed, which means `ownsSet` returned **true** there — so the
|
||||
record was really loaded and really matched, and the case is testing the Pending disjunct
|
||||
and nothing else. Same argument for `record_moved_on` and `!ownsSet`.
|
||||
|
||||
### H2/H3 — closed to the extent it can be
|
||||
|
||||
`current.Pending` is now individually killed (M2), and `!ownsSet` by two tests (M3).
|
||||
`!found` survives (M1) — correctly, as an equivalent mutant.
|
||||
|
||||
### M1 (`resumed.Name`) — closed
|
||||
|
||||
M5 is killed with a specific message. The control M6 confirms the sibling title
|
||||
assertion was not weakened while the file was edited.
|
||||
|
||||
## The `!found` equivalent-mutant claim — CONFIRMED
|
||||
|
||||
Independently verified three ways, not just by the surviving mutation:
|
||||
|
||||
1. **Source.** `getPack` (`internal/modules/sticker/pack.go:89-98`) returns a literal
|
||||
`Pack{}` on *both* non-found paths (`ErrNotFound` and error), and the error path
|
||||
returns early at the call site. So at the guard, `found == false` implies
|
||||
`current == Pack{}` implies `current.Name == ""`.
|
||||
2. **`ownsSet`.** `internal/modules/sticker/setname.go:80-82` returns false when
|
||||
`pack.Name == ""`. Hence `!ownsSet(current, _)` is already true whenever `!found`,
|
||||
and `current.Pending` is false, so the mutated expression is bit-for-bit identical
|
||||
on every reachable input.
|
||||
3. **The equivalence is itself pinned.** `internal/modules/sticker/setname_test.go:79`
|
||||
asserts `ownsSet(Pack{}, "anything") == false`. This is the property the redundancy
|
||||
depends on, so a future edit to `ownsSet` that breaks the equivalence — making
|
||||
`!found` load-bearing and silently untested — fails a test rather than passing
|
||||
quietly. This is the one thing that made the claim safe to accept rather than merely
|
||||
plausible.
|
||||
|
||||
Searched for a counterexample and found none:
|
||||
|
||||
- **Can a missing record yield a non-empty `current.Name`?** No. Both miss paths in
|
||||
`getPack` discard the decoded value and return the zero `Pack`.
|
||||
- **Can `action.SetName` be empty?** It is written once, at
|
||||
`delpack_callback.go:65` (`SetName: pack.Name`), from a record that is already proven
|
||||
`found && !pack.Pending`. Even if a corrupt store record carried `Name == ""`, `ownsSet`
|
||||
returns false for an empty `setName` too, so the guard refuses. Fail-closed either way,
|
||||
and `DeleteStickerSet` is never reached with an empty name.
|
||||
|
||||
Keeping `!found` with the comment is the right call: it costs nothing and the alternative
|
||||
is a guard whose correctness silently depends on a helper's empty-string branch.
|
||||
|
||||
## Correctness of the reverted `resumed.Name` (not just coverage)
|
||||
|
||||
The divergence only exists after a BotFather rename: `makeSetName` is deterministic in
|
||||
`(slug, username)` and the branch requires `existing.Slug == slug`, so `intent.Name !=
|
||||
existing.Name` is *only* possible when the bot's username changed between the interrupted
|
||||
attempt and the retry. Both sub-cases were driven with a probe test:
|
||||
|
||||
**(a) The interrupted attempt did create the set.** `createPack` probes
|
||||
`GetStickerSet(existing.Name)`, finds it, drops the intent, releases the slug and answers
|
||||
"name taken". The old set is stranded but the user is told. Refreshing the name instead
|
||||
would have probed the *new* name, found it free, and created a second set — orphaning the
|
||||
first one silently. The revert is the better behaviour here.
|
||||
|
||||
**(b) The interrupted attempt never created the set.** Probe output — the resume path
|
||||
really does send the stale name to Telegram:
|
||||
|
||||
```
|
||||
PROBE getStickerSet name="mypack_by_oldbot"
|
||||
PROBE createNewStickerSet name="mypack_by_oldbot"
|
||||
```
|
||||
|
||||
Real Telegram refuses a create whose short name does not end in `_by_<current username>`.
|
||||
This is the one place the revert costs something, so I drove it rather than assuming.
|
||||
Injecting Telegram's actual refusal:
|
||||
|
||||
```
|
||||
PROBE after refusal: found=false pack={} # intent dropped
|
||||
PROBE reservation still held: false # slug released
|
||||
PROBE reply="Telegram rejected that pack name. Use lowercase letters, digits and single underscores."
|
||||
PROBE attempt2 createNewStickerSet name="mypack_by_testbot"
|
||||
PROBE attempt2 stored = {Slug:mypack Name:mypack_by_testbot ... Pending:false}
|
||||
```
|
||||
|
||||
`createRefused` (`errors.go:112-123`) already matches `PACK_SHORT_NAME_INVALID` /
|
||||
`"invalid sticker set name"`, so the refusal is classified as proof-nothing-was-created,
|
||||
the stale intent and reservation are torn down, and the very next `/newpack` succeeds under
|
||||
the current username. **There is no permanent wedge** — the cost is one misleading error
|
||||
message in a rename-plus-interrupted-attempt window. That is strictly cheaper than the
|
||||
silent orphan in (a), so the revert is correct, not merely test-pinned.
|
||||
|
||||
Answering the two specific questions asked:
|
||||
|
||||
- *Stored `Name` never validated?* Every write of `Pack.Name` in production goes through
|
||||
`makeSetName`, which errors on an empty username and enforces `maxSetNameLen`. A record
|
||||
with an unvalidated or empty `Name` cannot be produced by this module, and if one
|
||||
existed, both `ownsSet` and the create probe fail closed.
|
||||
- *Stored `Name` belonging to a different slug?* Unreachable in this branch, which is
|
||||
gated on `existing.Slug == slug`; a differing slug routes to `resolveStaleIntent`, which
|
||||
re-reads the reservation before touching anything under the old name.
|
||||
|
||||
## Scan of `5e4fb0f` for anything new or unintended
|
||||
|
||||
Five files: two production (comment-only + one deleted line), two test, one report.
|
||||
|
||||
- `delpack_callback.go`: **comment only**. No behaviour change.
|
||||
- `pack_handlers.go`: one line deleted, replaced by a comment. Verified by M5/M6 that the
|
||||
surviving `resumed.Title` assignment is unchanged and still pinned.
|
||||
- No new production code, no new helper, no new abstraction, no `any` widening, no lint
|
||||
suppression, no error swallowed.
|
||||
- The replaced test dropped its seeding of the victim's *slug reservation*. That seeding
|
||||
was never asserted on in the old test either, so no assertion was weakened — the victim's
|
||||
pack record check is retained in every table case.
|
||||
- No phantom tests: every new test is mutation-killed (M4, M5) except by design.
|
||||
- No scope drift; nothing outside `internal/modules/sticker/` and `plans/reports/`.
|
||||
|
||||
## Gates
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `go vet ./...` | clean |
|
||||
| `go test ./...` | all pass |
|
||||
| `go test -race ./...` | all pass |
|
||||
| `go test -race -count=20 ./internal/modules/sticker/` | ok, 87.3s, no races, no flakes |
|
||||
| `golangci-lint run ./...` | `0 issues.` |
|
||||
| `gofmt -l .` | empty |
|
||||
|
||||
## Informational (non-blocking)
|
||||
|
||||
1. **M7 survivor.** Nothing pins that the guard clears the stale `PendingDelete` before
|
||||
refusing. Removing `s.dropPendingDelete(ctx, key)` from the guard body leaves the whole
|
||||
suite green. This is *not* an authority hole — the guard still refuses on every
|
||||
subsequent press, and the confirmation expires on its own — so it is cleanup hygiene, not
|
||||
safety. Worth one assertion in the table (`pending.Get` returns `ErrNotFound` after the
|
||||
refusal) if a future round touches this file; not worth blocking on.
|
||||
2. **Misleading refusal text after a bot rename.** In case (b) above the user is told their
|
||||
*pack name* is invalid when the real cause is that the bot was renamed. Cosmetic, rare,
|
||||
self-healing on retry.
|
||||
3. The `break_` field name in the table trips no linter under the project's config
|
||||
(`0 issues.`), so it is left alone.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
None.
|
||||
@@ -1,227 +0,0 @@
|
||||
# Adversarial verification — sticker packs, round 4
|
||||
|
||||
Scope: round-4 fixes on `feature/sticker-pack-module` (whole module lives in one
|
||||
commit `d831747`, so round-3→4 cannot be isolated by git; current state reviewed).
|
||||
Method: source read + four constructed attacks executed against the real handlers
|
||||
in a throwaway copy of the repo, plus a 45s fuzz of the emoji splitter and an
|
||||
image-pipeline probe. `go vet`, `go test ./...` (25 pkgs), `golangci-lint run` all
|
||||
clean on the branch as committed. No repo file was modified.
|
||||
|
||||
Verdict: **DO_NOT_MERGE** — change 1 does not close the takeover it was written for.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL 1 — cross-user pack takeover still reachable; `freshReservation` is a per-invocation fact, not durable proof
|
||||
|
||||
`internal/modules/sticker/pack_handlers.go:352` (guard), `:365-374` (the branch that
|
||||
defeats it), `:289-305` (a second adopt branch that never consults the guard).
|
||||
|
||||
The guard's premise (`:329`, `:340-351`) is "a genuine interrupted attempt always
|
||||
re-enters having found its own reservation, never having made one". True. The
|
||||
converse it relies on — "an attacker naming a foreign set can only ever be the one
|
||||
who made the reservation" — is false, because the module deliberately **keeps** a
|
||||
fresh reservation whenever the lookup is inconclusive:
|
||||
|
||||
```go
|
||||
default:
|
||||
// Unknown. Keep both the intent and the reservation: the set may exist,
|
||||
// and re-running is how the user recovers.
|
||||
log.Error("sticker_newpack_lookup", "err", err)
|
||||
```
|
||||
|
||||
One inconclusive `GetStickerSet` converts the attacker's fresh reservation into a
|
||||
resumed one. The next invocation has `freshReservation == false` and adopts.
|
||||
|
||||
**Precondition (all routes):** the victim's slug reservation is absent while the set
|
||||
lives at Telegram. This is the exact state `docs/sticker-packs.md:122-127` documents
|
||||
("what a restart does when no database is configured") and promises is safe:
|
||||
"`/newpack` reports the name as taken rather than adopting a set it can no longer
|
||||
prove is yours." With `KV_PROVIDER` auto-detect (`cmd/server/main.go:259-279`), any
|
||||
deploy without `MONGO_URL` re-enters this state on **every restart**.
|
||||
|
||||
**Route A — two ordinary commands, no crash, no store error** (executed, reproduced):
|
||||
|
||||
1. Attacker sends `/newpack <victimslug> X`. `getStickerSet` answers 429 / 5xx /
|
||||
deadline-exceeded — not `STICKERSET_INVALID` — so the `default` branch keeps the
|
||||
attacker's intent *and* reservation. User sees "Something went wrong."
|
||||
2. Attacker sends the identical command again. `reserveSlug` conflicts, holder is the
|
||||
attacker → `created=false` → `createOrAdopt(..., freshReservation=false)` → set
|
||||
exists → **adopted**.
|
||||
|
||||
Observed reply: `Finished an earlier attempt at Mine. https://t.me/addstickers/mypack_by_testbot`,
|
||||
record `{Slug:mypack Name:mypack_by_testbot OwnerID:999 Pending:false Count:1}`.
|
||||
The attacker now holds `/delpack` (irreversible `DeleteStickerSet`), `/renamepack`,
|
||||
`/setpackicon`, `/delsticker` over the victim's set — all keyed by set name with no
|
||||
owner scoping.
|
||||
|
||||
Step 1 is attacker-inducible, not luck: Telegram 429s are per-bot and any user can
|
||||
provoke them, and after changes 3+4 the tail budget left for `GetStickerSet` is
|
||||
~3 s (`FetchContext` reserve), so a slow media leg alone produces
|
||||
`context deadline exceeded` → same `default` branch.
|
||||
|
||||
**Route B — `resolveStaleIntent` never checks the guard at all** (executed, reproduced).
|
||||
With intent + reservation for `<victimslug>` surviving (e.g. a crash at the refusal
|
||||
point, before `dropIntent`), the attacker runs `/newpack <anyothername>`;
|
||||
`claimSlug` → `resolveStaleIntent` re-proves only *who holds* the old reservation,
|
||||
finds the victim's set, and adopts at `:292-304`. Reply: "You already have a pack
|
||||
(mypack) from an earlier attempt — it has been restored."
|
||||
|
||||
**Route C — partial refusal cleanup** (executed, reproduced). `:359-361` is two
|
||||
independent, error-swallowing writes. If `releaseSlug` fails or the process dies
|
||||
between them, the attacker keeps the reservation and the next attempt adopts (Route A
|
||||
step 2 without needing step 1).
|
||||
|
||||
**Not exploitable (checked):** `dropIntent` is always keyed to `pack.OwnerID`, which
|
||||
is always the caller (`claimSlug` builds the intent, or `getPack(caller)` returns it);
|
||||
`releaseSlug` re-verifies the holder at `:204`. **User A cannot drop user B's intent
|
||||
or reservation.** That part of round 4 holds.
|
||||
|
||||
**Fix (prototyped and verified).** Replace the per-invocation inference with durable
|
||||
positive evidence: a `Probed bool` on `SlugReservation`, set only when
|
||||
`GetStickerSet` positively answers `STICKERSET_INVALID` for a reservation this owner
|
||||
holds, written *before* `CreateNewStickerSet`, and required by **both** adopt
|
||||
branches. Fails closed: if the flag write fails, abort before creating.
|
||||
|
||||
```go
|
||||
// createOrAdopt, err == nil branch
|
||||
if freshReservation || !s.probedClear(ctx, pack.OwnerID, pack.Slug) { ...refuse... }
|
||||
|
||||
// createOrAdopt, isStickerSetMissing branch, before CreateNewStickerSet
|
||||
if !s.markProbed(ctx, pack.OwnerID, pack.Slug) { return reply(ctx, b, msg, genericFailure) }
|
||||
|
||||
// resolveStaleIntent
|
||||
case err == nil && !held.Probed: // refuse: release old reservation, take the new intent
|
||||
```
|
||||
|
||||
With this applied, all four attack routes refuse and the entire existing suite stays
|
||||
green — one fixture needs updating (`seedInterrupted` in `pack_handlers_test.go:394`
|
||||
must seed `Probed: true`, since it models a post-probe interrupted attempt).
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM 2 — the slug-ownership check still runs *after* the media pipeline
|
||||
|
||||
`pack_handlers.go:94` (`resolveSource`) precedes `:109` (`reserveSlug`).
|
||||
|
||||
Change 3's comment (`:81-84`) says making a user who cannot create a pack pay for the
|
||||
full media pipeline "was free work for anyone who wanted to spend the bot's CPU" —
|
||||
but that is still exactly what happens when the slug belongs to someone else.
|
||||
Executed: `/newpack <slug-held-by-another-user>` replying to a photo issued `getFile`
|
||||
and the file download before any slug check; on a decodable image it also resamples
|
||||
and `UploadStickerFile`s. Methods recorded: `[getFile x.jpg sendMessage]`.
|
||||
|
||||
Impact, single dispatch worker (`WithNotAsyncHandlers`, one worker): each such message
|
||||
occupies the bot for the whole leg, and the attacker never acquires a pack so the new
|
||||
`Pending` precheck never starts refusing them — the loop is unbounded. Measured
|
||||
`toStickerPNG` cost on the single worker: 0.71 s for a flat 4096×4096 source (360 KB
|
||||
on the wire) and 3.7 s for a noisy one (the ladder rungs). `mediaContext` does not
|
||||
bound this — `toStickerPNG` takes no context and cannot be interrupted. Wasted
|
||||
`UploadStickerFile` calls also burn the bot's API quota, which is the 429 that
|
||||
Finding 1 step 1 needs.
|
||||
|
||||
Fix: a read-only `getSlugReservation` before `resolveSource` — refuse when held by
|
||||
another user. Read-only, so it does not reintroduce the round-1 name-burning DoS
|
||||
(the create-only `reserveSlug` write stays where it is).
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM 3 — detached commit contexts are not actually protected at shutdown
|
||||
|
||||
`state.go:53-61`, `cmd/server/main.go:220-227`, `:125`.
|
||||
|
||||
Change 2 extends `context.WithoutCancel` to the reads, on the stated grounds that "a
|
||||
commit that records a completed Telegram-side action must not be lost because the
|
||||
process is shutting down". `main` does not honour that: on SIGTERM it cancels
|
||||
`rootCtx`, calls `srv.Shutdown` on the health server (returns in ms with no live
|
||||
connections), then returns — running `defer closeProvider()`, which disconnects Mongo.
|
||||
Nothing waits for the in-flight inline handler. The detached context survives
|
||||
cancellation but the process does not wait for the write, so a deploy can still lose
|
||||
the commit that these comments promise is safe.
|
||||
|
||||
Fix: track in-flight dispatch with a `sync.WaitGroup` (or a drain deadline) before
|
||||
`closeProvider`, or downgrade the comments to "best effort".
|
||||
|
||||
---
|
||||
|
||||
## LOW 4 — `downloadTimeout` is now dead
|
||||
|
||||
`download.go:25,39` vs `state.go:78-80`. `FetchContext` reserves 3 s of a 10 s
|
||||
handler, so `mediaCtx` is ≤ ~7 s and the 8 s `http.Client.Timeout` can never bind.
|
||||
The comment still calls it "this module's own ceiling". Either lower it to match the
|
||||
real budget or say it is a backstop for a caller with no deadline.
|
||||
|
||||
## LOW 5 — `lockUser`'s stated rationale is not true for this module
|
||||
|
||||
`state.go:82-84` claims "the cron scheduler and the detached per-command stats hook
|
||||
run concurrently with them, so this is load-bearing". Grepped: `keylock` here is
|
||||
`state`-local, the sticker module registers no cron jobs (`sticker.go:21-88`), and the
|
||||
stats hook (`dispatcher.go:82-90`) touches only the stats collection. Nothing else
|
||||
acquires these keys, and all sticker handlers run inline on the single dispatch
|
||||
goroutine. Keep the lock (cheap, correct if dispatch ever goes async) but fix the
|
||||
claim. Corollary: change 3 cannot deadlock or block the worker — verified, see below.
|
||||
|
||||
## LOW 6 — nested `commitContext` re-arms the budget
|
||||
|
||||
`pack_handlers.go:483` passes an already-detached ctx into `dropPackRecord:520`, which
|
||||
derives another. `context.WithoutCancel` drops the parent deadline, so the inner
|
||||
helper gets a fresh 5 s, and `dropPackRecord` → `releaseSlug` adds a third. A
|
||||
`/delpack` confirm can therefore spend ~15 s in detached cleanup. No leak (every
|
||||
`cancel` is deferred) and every op is bounded; just not the 5 s the constant implies.
|
||||
|
||||
## LOW 7 — recording bot truncates instead of rejecting oversized bodies
|
||||
|
||||
`testutil/recording_bot.go:195` reads through `io.LimitReader(r.Body, 8<<20)`; a body
|
||||
above the cap is silently truncated and then fails `ParseMultipartForm` as a confusing
|
||||
"bad multipart form: unexpected EOF" rather than a size error. No current test is near
|
||||
the cap. Reading one extra byte and reporting "body too large" would match the
|
||||
module's own `downloadFile:75` pattern.
|
||||
|
||||
## LOW 8 — duplicated refusal text
|
||||
|
||||
`pack_handlers.go:89-91` and `:248-250` build the same "You already have a pack" reply
|
||||
independently. One helper; they will drift.
|
||||
|
||||
---
|
||||
|
||||
## Attacked and held
|
||||
|
||||
- **Cross-user destruction of state.** `releaseSlug` re-reads and compares the holder
|
||||
(`:204`) and `dropIntent` is always keyed by the caller's own id. Probed both adopt
|
||||
refusal paths: user A cannot drop user B's intent or reservation. Held.
|
||||
- **Deadlock / worker starvation from change 3.** `WithNotAsyncHandlers` +
|
||||
one worker (`internal/telegram/client.go:26-30`) means all sticker handlers run
|
||||
serially on one goroutine; the `keylock.Map` is `state`-local; no cron, no hook, no
|
||||
detached goroutine touches it; no handler nests a second `lockUser`. Held (the lock
|
||||
is uncontended today).
|
||||
- **`Pending` record behaviour after moving the precheck.** The precheck refuses only
|
||||
`found && !existing.Pending`, so a pending user still falls through to the same
|
||||
`claimSlug` resume path as before. No behaviour change. Held.
|
||||
- **Change 5, extreme aspect ratios.** Executed 4096×1, 1×4096, 4096×3, 1×1,
|
||||
4096×4096: outputs 512×1, 1×512, 512×1, 512×512, 512×512 — no zero dimension, long
|
||||
edge exactly 512, and the ladder's targets still derive from the *original* bounds
|
||||
so the aspect is identical to the one-step version. Thumbnail path stays 100×100.
|
||||
Held.
|
||||
- **Change 6, emoji clustering.** 45 s / 526k-exec fuzz over valid UTF-8: no panic, no
|
||||
infinite loop, no empty cluster, and nothing dropped except leading/trailing ZWJ and
|
||||
whitespace. Spot-checked the singleton table against the standard non-block emoji
|
||||
set (©, ®, ‼, ⁉, ™, ℹ, Ⓜ, ⤴, ⤵, 〰, 〽, ㊗, ㊙) — complete. Held.
|
||||
- **Change 7, other modules' tests.** 28 files reference `NewRecordingBot`; full
|
||||
`go test ./...` green. The library always sends `multipart/form-data` with a
|
||||
boundary header and only omits the body for nil-param methods
|
||||
(`raw_request.go:29-72`), so "empty body ⇒ skip parse, non-empty ⇒ must parse" is
|
||||
the correct split, and replacing `r.Body` after buffering works because the boundary
|
||||
comes from the unchanged header. Held.
|
||||
- **`/delpack` callback authorisation.** Lookup keyed by presser, owner compared,
|
||||
chat+message binding before any side effect, action consumed before the destructive
|
||||
call. Held.
|
||||
- **Token leakage.** `classify` inspects error *types* only; `errDownloadFailed`
|
||||
replaces every download error; `replyErr` echoes only `userError`. Held.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Is production on Mongo or on the auto-detected memory backend? Finding 1 is routine
|
||||
after any restart on memory, and needs data loss on Mongo. It should be fixed either
|
||||
way, but this decides whether it blocks the deploy or only the config.
|
||||
2. `docs/sticker-packs.md:122-127` states the wiped-store refusal as a guarantee. It is
|
||||
currently only true for the first attempt; the doc needs no change once Finding 1 is
|
||||
fixed, but it should not ship as-is.
|
||||
@@ -1,259 +0,0 @@
|
||||
# Round-4 Verification — sticker module (adversarial, mutation-driven)
|
||||
|
||||
Date: 2026-08-25 · Branch `feature/sticker-pack-module` @ `30fa3b3` (identical to
|
||||
`origin/feature/sticker-pack-module`) · Go 1.27.0 linux/arm64 · golangci-lint v2.13.1
|
||||
|
||||
Method: every claim tested by mutating source and re-running the package, not by
|
||||
reading. 34 mutations applied and reverted. All files restored byte-identical
|
||||
(md5 verified, `git status --short` empty).
|
||||
|
||||
## Verdict
|
||||
|
||||
**All six round-4 claims verified.** No claim refuted. But three *previously
|
||||
claimed* guarantees are unpinned and one test name is still misleading (the same
|
||||
class of defect as round 3's `...SurvivesABail`). None of this is a new production
|
||||
defect; it is test-coverage overstatement.
|
||||
|
||||
## 1. Mutation results
|
||||
|
||||
### Round-4 claims under test
|
||||
|
||||
| # | Mutation | Result | Killing test |
|
||||
|---|---|---|---|
|
||||
| M1a | `handleNewPack`: delete `if created { s.releaseSlug(...) }` | **KILLED** | `TestNewPack_FreshReservationReleasedWhenClaimBails` |
|
||||
| M1b | `handleNewPack`: make the release unconditional | **KILLED** | `TestNewPack_ResumedReservationNotReleasedWhenClaimBails` |
|
||||
| M2 | `createOrAdopt`: remove the `if freshReservation` adoption gate | **KILLED** | `TestNewPack_WipedStoreCannotAdoptSurvivingPack` |
|
||||
| M3 | `handleAddSticker`: early `return nil` (handler is a no-op) | **KILLED** | `TestAddSticker_EmojiPrecedence` (+both subtests), `TestAddSticker_FallsBackToDefaultEmoji`, +6 others |
|
||||
| M3b | invert precedence: source emoji beats explicit args | **KILLED** | `TestAddSticker_EmojiPrecedence/explicit_wins` |
|
||||
| M3c | drop source-emoji inheritance | **KILLED** | `TestAddSticker_EmojiPrecedence/inherits_from_replied_sticker` |
|
||||
| M4 | remove `"sticker": sticker.New` + import from `factories()` | **KILLED** | `TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata` — and by the **reverse check specifically**: 9 lines `command_menu_test.go:133: /<cmd> is expected but not registered` |
|
||||
| M5 | `recording_bot.handle`: tolerate any multipart parse failure (`if err == nil`) | **KILLED** | `TestRecordingBot_RejectsMalformedMultipart` |
|
||||
| M6a | `isBinding`: disable tag-block (`E0020..E007F`) binding | **KILLED** | `TestParseEmoji_ClusterEdgeCases/tag_sequence_flag` |
|
||||
| M6b | `splitClusters`: ZWJ absorbs the next rune unconditionally | **KILLED** | `.../joiner_before_a_flag` |
|
||||
| M6c | `trimJoiners` → identity | **KILLED** | `.../joiner_before_a_flag`, `.../trailing_joiner` |
|
||||
| M6d | `isEmojiCluster`: accept a lone regional indicator | **KILLED** | `.../lone_regional_indicator`, `.../odd_regional_indicator_count` |
|
||||
| M6e | drop each of the 9 new `emojiSingletons` entries, one at a time | **9/9 KILLED** | `.../copyright`, `/registered`, `/arrow_curving_up`, `/arrow_curving_down`, `/circled_m`, `/wavy_dash`, `/part_alternation`, `/japanese_congratulations`, `/japanese_secret` |
|
||||
|
||||
Claims 1-6: **verified, all directions.** Claim 1's two-direction pinning is real —
|
||||
M1a and M1b are killed by *different* tests, and by only one test each.
|
||||
|
||||
### Additional adversarial mutations (not claimed, run to find gaps)
|
||||
|
||||
| # | Mutation | Result | Killing test |
|
||||
|---|---|---|---|
|
||||
| M8 | `createOrAdopt` unknown-error branch releases the slug | KILLED | `TestNewPack_UnknownLookupErrorAborts`, `TestNewPack_ResumedReservationSurvivesABail` |
|
||||
| M10 | `releaseSlug`: drop its **own** ownership check | **SURVIVED** | — |
|
||||
| M11b | `resolveStaleIntent`: skip the reservation-owner re-proof | KILLED | `TestNewPack_StaleIntentCannotAdoptForeignName` |
|
||||
| M12b | `reserveSlug`: treat another user's reservation as resumable | KILLED | `TestNewPack_CannotSeizeAnotherUsersPack`, `TestNewPack_ForeignReservationRefusedBeforeAnyAPICall` |
|
||||
| M13 | refused adoption leaves intent + reservation behind | KILLED | `TestNewPack_WipedStoreCannotAdoptSurvivingPack` |
|
||||
| M14 | `releaseSlug`: read reservation on request ctx, not `commitContext` | **SURVIVED** | — |
|
||||
| M16b | delpack callback: drop `query.From.ID != action.OwnerID` | **SURVIVED** | — |
|
||||
| M17 | delpack callback: drop the message-binding check | KILLED | `TestDelPackCallback_RejectsWrongBinding` (+subtests), `TestDelPackCallback_BystanderCannotTouchAnotherUsersPrompt` |
|
||||
| M18 | delpack callback: drop the expiry check | KILLED | `TestDelPackCallback_RejectsExpired` |
|
||||
| M19b | delpack callback: drop the `action.ID != id` nonce check | **SURVIVED** | — |
|
||||
| M6e' | drop each pre-existing singleton `203C`, `2049`, `2122`, `2139` | **4× SURVIVED** | — |
|
||||
| M7 | drop each `emojiRanges` entry, one at a time | 3 KILLED (`1F300`, `2600`, `2B00`) / **5 SURVIVED** (`1F000`, `2190`, `2300`, `25A0`, `1F1E6`) | — |
|
||||
|
||||
**Score: 29 killed / 34 mutation slots, 12 survivors across 5 distinct sites.**
|
||||
|
||||
## 2. Emoji differential probe (full rune space)
|
||||
|
||||
Temp in-package test walked `0x0..0x10FFFF` comparing `isEmojiRune` against a
|
||||
reconstructed round-3 switch (same 8 blocks + the 4 pre-existing singletons).
|
||||
`emoji.go` exists in exactly one commit (`d831747`) on this branch and nowhere in
|
||||
history/reflog/other branches, so the old switch could not be recovered verbatim
|
||||
— it was reconstructed from the range table plus the round-3 correctness report
|
||||
(`correctness-review-260825-1515-sticker-module.md:116-131`, which enumerates the
|
||||
9 refused codepoints and names `2122`/`2139` as already special-cased).
|
||||
|
||||
```
|
||||
deliberate additions observed: 9 of 9 -> [U+00A9 U+00AE U+24C2 U+2934 U+2935 U+3030 U+303D U+3297 U+3299]
|
||||
UNEXPECTED classification changes: 0
|
||||
```
|
||||
|
||||
**Result: zero unexpected classification changes.** The switch→array+map
|
||||
restructure is behaviour-preserving modulo exactly the 9 intended additions.
|
||||
|
||||
Second, non-circular probe against authoritative Unicode data
|
||||
(`unicode.org/Public/UCD/latest/ucd/emoji/emoji-data.txt`, 1288 lines):
|
||||
|
||||
```
|
||||
accepted-but-not Emoji/Extended_Pictographic: 2140 (e.g. U+2190..U+21FF arrows, U+25A0.. shapes)
|
||||
Emoji-property runes rejected: 12 -> [U+0023 U+002A U+0030..U+0039]
|
||||
```
|
||||
|
||||
The 12 rejections are the keycap bases, handled by the `keycapCombining` branch in
|
||||
`isEmojiCluster` — not a defect. The 2140 false positives are the documented
|
||||
block-approximation trade-off (`emoji.go` comment: "Deliberately ranges rather
|
||||
than a property lookup"), pre-existing and low impact: Telegram answers
|
||||
`STICKER_EMOJI_INVALID` and `apiRefusal` renders a sane message. Not a round-4
|
||||
regression. Informational only.
|
||||
|
||||
## 3. New / still-wrong tests found
|
||||
|
||||
### F-1 (MEDIUM) — `TestNewPack_ResumedReservationSurvivesABail` still does not test what its name and comment claim
|
||||
|
||||
`pack_handlers_test.go:573`. Its comment says *"A reservation the caller merely
|
||||
resumed must not be released when a later step bails — it predates this command."*
|
||||
That is the `created`-flag distinction. Proven false by mutation:
|
||||
|
||||
- under **M1b** (release made unconditional — i.e. the resumed/fresh distinction
|
||||
deleted outright) this test still passes: `ok ... 0.019s`. Only
|
||||
`TestNewPack_ResumedReservationNotReleasedWhenClaimBails` catches M1b.
|
||||
- what it *actually* pins is M8: `createOrAdopt`'s unknown-error branch must not
|
||||
release. So does `TestNewPack_UnknownLookupErrorAborts`, which M8 also killed.
|
||||
|
||||
It is a duplicate of `TestNewPack_UnknownLookupErrorAborts` wearing the name of the
|
||||
round-4 test that replaced it. Round 4 correctly added the two real tests but left
|
||||
the misnamed one in place. Rename to `TestNewPack_UnknownLookupErrorKeepsTheReservation`
|
||||
or delete it.
|
||||
|
||||
### F-2 (MEDIUM) — `TestDelPackCallback_RejectsOtherUser` does not exercise the owner check
|
||||
|
||||
`delpack_callback_test.go`. **M16b survived**: deleting
|
||||
`if query.From.ID != action.OwnerID` from `delpack_callback.go:118` breaks no test.
|
||||
Reason: the record is fetched with `key := pendingDeleteKey(query.From.ID)` (`:108`),
|
||||
so a foreign presser has no record at all and exits three lines earlier via
|
||||
`ErrNotFound` → *"This confirmation expired or was already used."* The owner check
|
||||
is structurally unreachable; the test named for it passes through a different
|
||||
branch. Authorization is genuinely enforced (by the key), so this is a test-naming
|
||||
and dead-code issue, not a hole. Fix: assert the *reply text*, or drop the
|
||||
unreachable branch.
|
||||
|
||||
### F-3 (LOW) — `releaseSlug`'s own ownership check is unpinned
|
||||
|
||||
**M10 survived.** The code comment (`pack_handlers.go:176-181`) states the check
|
||||
exists precisely because *"this module has already been bitten once by an ownership
|
||||
check that lived in the caller instead of the operation."* Nothing tests it. Every
|
||||
current call site happens to pass the correct owner, so the check is presently
|
||||
redundant — which is exactly why a future call site could silently regress it.
|
||||
|
||||
### F-4 (LOW) — round-3's `commitContext` fix in `releaseSlug` is unpinned
|
||||
|
||||
**M14 survived.** Moving the ownership read back onto the request context breaks
|
||||
nothing: there is no cancelled-context test in the package (`grep context.WithCancel
|
||||
internal/modules/sticker/*_test.go` → 0 hits). The comment at `:183-190` describes a
|
||||
concrete failure mode (SIGTERM mid-release leaves an unreachable reservation) with
|
||||
no test behind it.
|
||||
|
||||
### F-5 (LOW) — delpack nonce check unpinned
|
||||
|
||||
**M19b survived.** Acknowledged in-code as defence-in-depth subsumed by the
|
||||
message binding (`:141-143`), so lower priority than F-2, but it is untested
|
||||
dead weight.
|
||||
|
||||
### F-6 (LOW) — dead `emojiRanges` entry with a load-bearing-sounding comment
|
||||
|
||||
`{0x1F1E6, 0x1F1FF}` is fully contained in `{0x1F000, 0x1F2FF}` — **M7 confirms both
|
||||
survive individual deletion**. Its comment ("regional indicators; isEmojiCluster
|
||||
requires a pair") reads as though it is required. It is not. Same for the untested
|
||||
`2190`, `2300`, `25A0`, `1F000` entries and the four pre-existing singletons
|
||||
(`203C`, `2049`, `2122`, `2139`) — all silently deletable.
|
||||
|
||||
### No vacuous-loop assertions remain
|
||||
|
||||
Swept every `for _, call := range rb.Sent()` in the changed packages. The four hits
|
||||
are either negative assertions (correct as loops), counting helpers, or already
|
||||
gated by a preceding `countMethod(...) != 1` fatal. Round 4's `addedStickerPayload`
|
||||
helper is genuine — M3 proves it. Also scanned all 89 sticker tests + the new
|
||||
dispatcher/testutil/cmd tests for zero-assertion bodies: 4 heuristic hits, all
|
||||
false positives (the assertion is a `Fatalf` on `err == nil`).
|
||||
|
||||
## 4. Regression sweep — `internal/testutil/recording_bot.go`
|
||||
|
||||
26 test files across 18 packages construct a `RecordingBot`. All pass.
|
||||
|
||||
The behaviour change (non-empty body that fails multipart parse → 400 instead of
|
||||
200-with-empty-form) is scoped correctly:
|
||||
|
||||
- `go-telegram/bot@v1.20.0 raw_request.go:28-71` always sends multipart; when
|
||||
`params == nil` (parameterless methods) it skips both `buildRequestForm` and
|
||||
`form.Close()`, so the body is **zero bytes** — the `len(body) > 0` gate is the
|
||||
right discriminator. `TestRecordingBot_ServesParameterlessCall` confirms getMe.
|
||||
- The exact hazard the comment warns about exists in the repo:
|
||||
`internal/modules/stock/dividend_flow_test.go:115` and `:139` assert
|
||||
`Form["reply_markup"] != ""` is false. Both still pass — the change *protects*
|
||||
them rather than breaking them.
|
||||
- No caller asserts on a field only present under a file part.
|
||||
|
||||
`go test -race -count=1 ./...` — clean, no races, exit 0.
|
||||
`go test -count=20 ./internal/modules/sticker/` — `ok ... 7.964s`, no flakes.
|
||||
|
||||
Nit (non-blocking): `recording_bot.go handle()` carries two overlapping comment
|
||||
paragraphs saying the same thing — a stale round-3 paragraph left above the
|
||||
round-4 one.
|
||||
|
||||
## 5. Gates
|
||||
|
||||
```
|
||||
golangci-lint run ./... → 0 issues.
|
||||
gofmt -l . (minus third_party) → (empty)
|
||||
go test -race -count=1 ./... → clean
|
||||
```
|
||||
|
||||
## 6. Final state (verbatim)
|
||||
|
||||
```
|
||||
$ git status --short
|
||||
$
|
||||
```
|
||||
(empty)
|
||||
|
||||
md5 of every tracked file diffed against the pre-review baseline: **MD5 IDENTICAL**.
|
||||
|
||||
```
|
||||
$ go test ./...
|
||||
ok github.com/tiennm99/miti99bot/cmd/server (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/cron (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/deploynotify (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/keylock (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/log (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/metrics (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/amlich (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/coin (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/gold (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/lol (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/loldle (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/misc (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/monkeyd (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/stats (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/sticker (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/stock (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/util (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/util/chathelper (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/modules/wordle (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/server (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/storage (cached)
|
||||
? github.com/tiennm99/miti99bot/internal/systemstate [no test files]
|
||||
ok github.com/tiennm99/miti99bot/internal/telegram (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/testutil (cached)
|
||||
ok github.com/tiennm99/miti99bot/internal/testutil/mongotest (cached)
|
||||
```
|
||||
|
||||
## 7. Recommended actions
|
||||
|
||||
1. **F-1** — rename or delete `TestNewPack_ResumedReservationSurvivesABail`. Third
|
||||
round running that a test in this file claims more than it proves; the name is
|
||||
what misled round 3.
|
||||
2. **F-2** — assert the reply text in `TestDelPackCallback_RejectsOtherUser`, or
|
||||
remove the unreachable `query.From.ID != action.OwnerID` branch.
|
||||
3. **F-3 / F-4** — add two small tests: a cross-owner `releaseSlug` call, and a
|
||||
`releaseSlug` under a cancelled parent context. Both are ~10 lines and pin
|
||||
fixes whose comments describe real past incidents.
|
||||
4. **F-6** — delete `{0x1F1E6, 0x1F1FF}` from `emojiRanges` or fix its comment.
|
||||
5. Drop the duplicated comment paragraph in `recording_bot.go handle()`.
|
||||
|
||||
None of 1-5 blocks merge. All are test/comment hygiene against real production
|
||||
behaviour that is correct today.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- The pre-round-4 `isEmojiRune` switch is unrecoverable from git (single squashed
|
||||
commit). The differential probe is therefore partly circular *for the singleton
|
||||
set* — it is fully independent for the eight ranges. If the round-3 source is
|
||||
available elsewhere, re-running the probe against the verbatim original would
|
||||
close the last gap.
|
||||
- Are the four pre-existing singletons (`203C ‼`, `2049 ⁉`, `2122 ™`, `2139 ℹ`)
|
||||
intentionally unlisted in `TestParseEmoji_ClusterEdgeCases`, or an oversight when
|
||||
round 4 added the nine?
|
||||
Reference in new issue
Block a user