Commit Graph
120 Commits
Author SHA1 Message Date
tiennm99 e158319981 docs(plans): record the measured meteor curve from reference footage 2026-10-01 14:12:03 +07:00
tiennm99 4fa0e10384 docs(plans): record gacha research and the rarity prefix format 2026-10-01 13:59:12 +07:00
tiennm99 b3c46a2f2f docs(plans): note the switch to uniform gacha odds 2026-10-01 13:09:08 +07:00
tiennm99 070f303b35 docs(plans): record the gacha wish command plan 2026-10-01 13:02:46 +07:00
tiennm99 f748d34095 feat(thoitiet): add today, tomorrow, and 7-day weather commands 2026-10-01 10:28:10 +07:00
tiennm99 97aaba83ec feat(misc): add /giaxang Petrolimex retail fuel prices 2026-09-30 11:44:42 +07:00
tiennm99 c47bcf2c7f feat(blacklist): show the changed list after every add and remove
/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.
2026-09-15 13:48:34 +07:00
tiennm99 e5125fcd93 feat(blacklist): add per-topic text deny-list with whitelist exceptions
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.
2026-09-15 13:37:03 +07:00
tiennm99 5826995c31 fix(misc): make /xlt1 public
Filing the petition is the group joke, so it should not be admin-gated
the way /ff is. Its denial test becomes a non-admin allow test.
2026-09-02 16:16:20 +07:00
tiennm99 4f4817b597 docs: add đơn xin lỗi research behind the /xlt1 template 2026-09-02 16:03:36 +07:00
tiennm99 6c7db15bb3 docs(plans): add round 6-7 verification reports 2026-08-25 17:19:04 +07:00
tiennm99 2d42f40ca7 test(sticker): pin the delpack authority guard, keep the stored set name
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.
2026-08-25 17:09:19 +07:00
tiennm99 23d3f67ca3 fix(sticker): prove authority before a confirmed pack delete
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.
2026-08-25 16:49:37 +07:00
tiennm99 1810c1ee47 fix(sticker): never adopt an existing pack
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.
2026-08-25 16:28:11 +07:00
tiennm99 a077b75d04 docs(plans): record sticker module delivery and review findings
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.
2026-08-25 15:54:48 +07:00
tiennm99 71cf0006a6 docs(plans): revise sticker packs plan to one pack per user
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.
2026-08-25 11:08:22 +07:00
tiennm99 49ffb2b68a docs(plans): add sticker packs module plan
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
2026-08-25 09:32:22 +07:00
tiennm99 23b8f1a7c9 docs: add amlich improvement research, brainstorm decision, and completed plan 2026-08-18 23:00:39 +07:00
tiennm99 ffe9fb22e3 docs: remove completed plans, superseded reports, and journals
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.
2026-08-16 22:47:32 +07:00
tiennm99 d1ef691ff6 docs: add lol schedule research, tgs feasibility reports, and pandascore journal 2026-08-10 10:00:53 +07:00
tiennm99 547bd9be07 docs(amlich): add algorithm comparison, calendar rules research, and known-issues reports
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.
2026-08-08 23:27:14 +07:00
tiennm99 58c42c312b feat(lol): replace schedule source with PandaScore API
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.
2026-08-05 17:39:20 +07:00
tiennm99 5ed267d228 docs(lol): add leaguepedia score enrichment research and 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.
2026-07-26 12:57:56 +07:00
tiennm99 5ca7e343ac docs(stock): record stock info delivery 2026-07-23 12:27:12 +07:00
tiennm99 d1d17af7c4 docs(stock): finalize stock-events delivery 2026-07-23 10:08:47 +07:00
tiennm99 71dcef70c0 docs(stock): add stock-events plan artifacts 2026-07-23 10:05:44 +07:00
tiennm99 8fcaf07bc3 docs(stock): record dividend retirement workflow 2026-07-22 18:56:07 +07:00
tiennm99 e46d10cc36 docs(portfolio): document mobile column layout 2026-07-22 17:36:29 +07:00
tiennm99 df240a5db0 feat(stock): persist per-user dividend history 2026-07-22 16:17:29 +07:00
tiennm99 7a0163ec01 feat: format compact portfolio numbers 2026-07-22 12:27:52 +07:00
tiennm99 cc7dd4aa10 docs: remove completed implementation records 2026-07-21 19:13:34 +07:00
tiennm99 ee2c520944 docs(stock): document dividend event buttons 2026-07-21 18:20:14 +07:00
tiennm99 53718dc8b8 docs(stock): research dividend event APIs 2026-07-21 17:13:43 +07:00
tiennm99 96ba34384a refactor(portfolio): retire completed migrations 2026-07-21 17:13:19 +07:00
tiennm99 7711029f4c docs(portfolio): document cost basis and P&L 2026-07-21 15:52:30 +07:00
tiennm99 d2c3129d41 refactor(commands): standardize parameter conventions 2026-07-21 14:51:28 +07:00
tiennm99 6ceb7aff02 docs(stock): document dividend commands 2026-07-20 17:33:10 +07:00
tiennm99 8e748c3153 docs(plans): remove plan artifacts 2026-07-07 10:19:51 +07:00
tiennm99 00dc4c7800 feat(misc): add wheelofnamesbeta render API 2026-07-07 00:12:58 +07:00
tiennm99 0a4d237391 fix(misc): restore wheel beta renderer 2026-07-06 15:30:29 +07:00
tiennm99 e7154d27f3 feat(misc): render wheel beta with canvas 2026-07-06 15:18:47 +07:00
tiennm99 3763b293d6 feat(misc): add wheelofnames beta animation 2026-07-06 11:41:20 +07:00
tiennm99 9308358a23 docs: record wheelofnames implementation 2026-07-03 11:12:49 +07:00
tiennm99 8f0286e54f feat(stats): use queryable Mongo usage records 2026-07-01 10:22:03 +07:00
tiennm99 d9c7421c7e chore(plans): remove completed planning artifacts 2026-06-29 09:15:29 +07:00
tiennm99 5971347a6b feat(wc): add world cup schedule module 2026-06-29 00:59:30 +07:00
tiennm99 d56c95cff2 build(coolify): rename compose file 2026-06-29 00:00:22 +07:00
tiennm99 98a5cea386 docs(reports): add AWS footprint and cleanup audit 2026-06-28 23:53:12 +07:00
tiennm99 15a3239ab1 refactor(storage): replace KVStore with generic typed DocStore[T]
Delete the byte-oriented KVStore/VersionedStore abstraction and the
DynamoDB/memory KV backends. Add a generic typed store (DocStore[T] with
Provider/Collection/Typed) persisting each value as a flattened native
Mongo document (storedDoc[T] via bson inline) — no value envelope.

- MongoDB is the only runtime backend; memory kept for tests/local.
- All modules + deploynotify use typed stores; persisted structs carry
  bson tags == json names (incl. nested lolschedule/wordle types).
- lolschedule wraps its array/scalar values in named structs.
- migrate-dynamo-to-mongo writes the flattened shape via Typed[bson.M]
  with wrap rules; Scan/--dry-run/--verify retained.

Verified: go vet/build clean; full go test green hermetically and
in-container vs real Mongo 7 + DynamoDB Local (storage integration +
migrator e2e).
2026-06-28 18:02:11 +07:00
tiennm99 b4910c2289 docs(plan): native mongo value documents + version CAS plan
Brainstorm decision record (Option A) + validated 2-phase TDD plan for
storing mongo values as native BSON and switching CAS to a version field.
2026-06-28 12:53:20 +07:00