The bot now reaches the renderer at http://renderer:3000/api/gif on the
compose network instead of a separately deployed service. Both share
WHEELOFNAMES_API_TOKEN. CI runs the renderer's lint, typecheck, tests,
render smoke, and image build.
/blacklist_add, /blacklist_del, /whitelist_add and /whitelist_del now answer
with the current contents of the list they touched, so the sender sees the
result without following up with /blacklist_rules.
All four outcomes end the same way, including the two that change nothing:
text already present, and text that was not there to remove. Those are
exactly when someone wants to see what the list holds, and "every add and
remove shows the list" is a simpler rule than one conditional on whether a
write landed.
The confirmation line shares the listing's byte budget, so a long list
cannot push the combined reply past Telegram's message limit.
Six public commands let any member of a chat curate two lists of text and
ask whether a given text is blocked: /blacklist_add, /blacklist_del,
/whitelist_add, /whitelist_del, /blacklist_rules and /blacklist_check.
The module is passive. It never reads ordinary chat messages and never
deletes, warns or restricts anyone; the lists stay inert until
/blacklist_check asks about a specific text.
Scope is one forum topic, keyed (Chat.ID, MessageThreadID) and gated on
IsTopicMessage so a reply chain in a plain supergroup does not become its
own unreachable scope. A plain group, a DM and a forum's General topic all
resolve to one chat-wide list.
Matching is substring, after NFKC composition, case folding and whitespace
collapse. Diacritics stay significant, so ma, má and mà are three entries.
A whitelist entry rescues a blacklist match only when it spans that match,
which is what keeps "I met an assassin, dumbass" blocked.
Entry text is percent-encoded before it becomes a storage key, since keys
forbid '/' and cap at 1500 bytes, and is capped at 200 bytes before and
after normalization because NFKC can expand as well as contract.
/blacklist_rules reads each list with Scan, so listing costs one round trip
per list rather than a Get per entry.
The previous commit's regression test never reached the guard it was
named for. It broke the pack record with dropPackRecord, which now also
clears the confirmation, so the callback returned at the pending.Get miss
long before the allowlist. The test passed with the entire guard
reverted - shipping the fix with its own detector inoperative, which is
the defect that let five earlier rounds report a false clean.
Replace it with a table that leaves the confirmation intact and breaks
the record three ways, one per disjunct: record gone, record unconfirmed,
record moved on. Reverting the guard now fails two cases; each disjunct
was mutated individually.
The !found disjunct is an equivalent mutant: ownsSet already returns
false for a zero-value record's empty Name, so no test can kill it. Kept
and commented, because that redundancy is an accident of ownsSet's
empty-string guard rather than something this check should rely on.
Also revert the set-name half of the previous commit's resume change.
Carrying the retyped title is right; re-deriving Pack.Name was not. The
name comes from the bot username, which can change at BotFather, and the
stored one identifies the set the interrupted attempt may already have
created - refreshing it orphaned that set and aimed later commands at a
different name, contradicting ownsSet's own documented rule. Pinned.
A /delpack confirmation outlived the record that authorised it. The
under-lock re-check listed the states it would refuse - a pending record
still naming this set - and fell through on the two that mattered: no
record at all, and a record that had moved on to a different pack.
Reachable with ordinary commands and no attacker: run /delpack without
pressing, let the pack disappear from Telegram's side so a self-heal
frees the name, let another user claim it, then press. DeleteStickerSet
is keyed by set name, which Telegram authorises for every set this bot
created, so the press destroys whoever holds the name at that moment.
Invert the guard: delete only when a confirmed record still names this
exact set. A check phrased as "which states do I refuse" cannot fail
closed against a state nobody enumerated. Dropping a pack record now also
clears any outstanding confirmation, so a dead prompt stops existing
rather than merely being refused on use.
This also stops the reservation leaking when a confirmed delete lands on
a record that has moved on, since that case no longer reaches Telegram.
Alongside:
- Resuming an interrupted /newpack discarded a retyped title and reported
success quoting the old one.
- TestNewPack_DifferentSlugReplacesDeadIntent was named for releasing a
dead name and never asserted it.
- 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. The lock stays for the read-modify-write pattern; a
wrong reason for a right guard misleads the next reader.
Two ordinary /newpack commands could take over a stranger's pack. The
first probe returns an inconclusive error, which correctly keeps the
reservation so the user can retry - but that turned a fresh claim into a
resumed one and defeated the guard that made adoption conditional.
resolveStaleIntent had a second adopt path that never consulted the
guard at all. Both are reproduced by tests added here.
This is the fourth failure of the same mechanism, and it is structural.
Adoption must prove "this set is mine to finish" from local state, and
local state is what a restart on the in-memory backend erases while the
packs at Telegram survive. With the proof gone, a genuine interrupted
attempt and a stranger naming a public share link are indistinguishable.
Remove adoption entirely. /newpack refuses any name a set already
occupies, and leaves no intent or reservation behind when it does.
A pending record is not evidence of ownership either: anyone can make one
naming any set, and DeleteStickerSet is keyed by set name, which Telegram
authorises for every set this bot created. /delpack therefore clears a
pending record locally and contacts Telegram only for a confirmed one.
The cost is that a crash between creating a set and recording it strands
that set. That is documented rather than mitigated - every mitigation
available is the mechanism that just failed.
Also drop a test whose name claimed to pin the resumed-reservation
distinction but bailed past the code that implements it, rename a delpack
test after the guard that actually stops a foreign presser, and pin
releaseSlug's ownership check and detached read - the latter needed a
context-honouring store, since the in-memory one ignores cancellation and
made the first version of that test vacuous.
Mark phases 1-5 done and phase 6 partial: the code and docs are
complete, but the live-token smoke checks and the deployed MODULES
change are not, and three questions about Telegram's own behaviour stay
open (file_id reuse across sets, the literal STICKERSET_INVALID string
the self-heal paths match on, and whether a deleted short name is
reclaimable).
Add the three-lens review pass to the revision log, and correct two
phase-03 checkboxes that shipped code contradicts - one of them ticked
against text the file's own superseding note already retracted.
Every command except /newpack drops its <pack> argument; the caller's
single pack is resolved implicitly. /packlist becomes /mypack, and the
store key is the user ID alone.
Resolves rather than mitigates the worst red-team finding: /packlist's
N+1 plus ten GetStickerSet calls under a 60s per-call ceiling is gone.
/mypack is one Get and makes no API calls. resolveOwned collapses to a
single Get. Both prior open questions are answered.
The slug survives on /newpack alone, where it fixes the permanent share
URL. Derived-from-user-id and opaque-id schemes were rejected: the first
publishes the owner's Telegram ID forever, the second is unbrandable.
/delpack reframed as the only way to change a pack URL, since Telegram
exposes no rename-short-name method:
- /renamepack's reply names the delete-and-recreate route instead of
only stating the link cannot change
- /delpack's confirm must state the title, the sticker count being
destroyed, the link being surrendered, and that both are permanent
- /repack migration rejected for this plan: up to ~121 sequential API
calls exceeds handlerTimeout and stalls the bot for all users under
C1. Viable only after the Phase 5 offload; recorded as a follow-up
Fixes a bug in the write-ahead intent machinery: the different-slug
pending branch overwrote unconditionally, permanently orphaning a set
created before an interruption. It now probes GetStickerSet first and
adopts when the old set exists.
Adds R11 — whether a deleted slug can be reclaimed is undocumented and
unresolvable without a live bot. Does not block the URL-change path,
which needs a different name. Settled by a new Phase 6 smoke step.
Phase 1 unchanged.
Plan for internal/modules/sticker: public, multi-pack-per-user Telegram
sticker set management using @Stickers command names, single-shot
reply+args instead of a conversational flow.
Six phases, 117 tasks. Phase 1 covers two shared-code prerequisites the
module would otherwise expose: no panic barrier on the update path, and
a test harness that cannot return structured API results.
Researched against the live Bot API and red-teamed by three adversarial
reviewers; 16 findings accepted, recorded in plan.md. Notable
corrections:
- MODULES is not opt-in; an empty value loads every module, so the
factories() entry is itself the enablement
- getStickerSet exposing no owner does not force "orphans cannot be
adopted"; a write-ahead intent record makes recovery sound
- the file-download URL embeds the bot token and reaches the dispatcher
log through url.Error, which logging file_id does not prevent
- /packlist ran ten API calls under a 60s per-call ceiling, a worse
stall than the photo pipeline the plan had been guarding
- the /delsticker probe deleted a live pack's record on any transient
error
- /help has 884 runes of headroom for nine new commands
All eight plans are completed or cancelled and their behavior is now
described in README. Three reports contradicted shipped code: one
recommended keeping the lolesports gql client over PandaScore, two
analyzed the transport that migration removed. The rest is
pre-implementation research whose conclusions live in the code.
Drop the conventions reference to the deleted schema research.
Research and A/B evaluation of the truncated-Meeus port vs a from-the-rules
Meeus ch.49 implementation: 9 disputed lunations in 1800-2199, both
historically verifiable ones side with the current implementation. Decision:
keep truncated-Meeus. Known-issues report scoped to dates from 1970 onward.
Swap the lol module upstream from the lolesports.com gql persisted-query
client to PandaScore REST (/lol/matches, Bearer LOL_PANDASCORE_TOKEN,
free tier 1000 req/h). The gql transport broke whenever Riot redeployed
their frontend; PandaScore is a stable versioned contract.
ScheduleEvent, formatters, cron, and the bson cache shape are unchanged:
only the transport and response mapping moved. PandaScore league slugs
canonicalize to the existing major-league allowlist; results join to
opponents by team_id so reversed arrays cannot swap scores; outcomes are
declared only once upstream commits a winner, preserving the
score-pending rendering. A still-full final page now logs
lol_page_budget_exhausted and the live page budget covers 500 raw
matches per window.
Missing token short-circuits with lol_token_missing before any upstream
call; the 60-minute stale cache still covers outages. Document the new
env var and cancel the superseded Leaguepedia score-enrichment plan.
Research: after lolesports began marking matches completed without
publishing results, evaluated independent score sources. Established
that wrappers of lolesports (lolesportsapi.com, Pupix, Apify) inherit
the gap, and that getCompletedEvents/getStandings are equally stale, so
only a source with its own ingestion helps. Leaguepedia is the only free
candidate; commercial providers run $2k-10k/mo.
Plan: 4 phases for filling missing gameWins/outcome from Leaguepedia's
Cargo API. lolesports stays authoritative for schedule and for any score
it does publish. Additive throughout - every failure path degrades to the
existing "score pending" line.
Joins on team name plus date rather than team codes: Riot already sends
Team.Name matching Leaguepedia's Team1/Team2, which removes the code
mapping table and the per-split OverviewPage handling that the research
had flagged as the main maintenance burden.
Phase 1 is a hard gate. Fandom rate-limited and then refused the
connection during research, so it was never confirmed that Leaguepedia
actually holds the scores Riot is missing. That check can cancel the plan.
Plan is pre-validation. A verification pass found three items not yet
recorded in the files: slices.ContainsFunc in phase 4 has no precedent in
this repo (explicit loops are the house style), the TTL index citation
should read startup.go:35-52, and the Bo2 tie handling in phase 3 is
unnecessary - all 26 allowlisted-league events are Bo3 with zero ties.