Commit Graph
100 Commits
Author SHA1 Message Date
tiennm99 21b9e596bc feat(gacha): put the moon emblem on the pack and card back
The pack prints a crescent moon in its diamond in place of the default star, and the card back carries the same moon in its diamond seal over the miti99bot caption.
2026-10-02 14:57:06 +07:00
tiennm99 dc65ce7df2 feat(gacha): show the card text on its last turn face up
The text switches on as the spinning card turns edge-on for the last time, so it rides in on the final turn instead of fading in after the spin stops.
2026-10-02 14:29:18 +07:00
tiennm99 b48f7602f9 feat(gacha): clear printed text and add a mirror glint on reveal
The pack and the card corners no longer carry text. The card's text stays hidden while it spins and fades in once it lies flat, then a mirror glint sweeps from the top-left corner to the bottom-right as the card comes to rest.
2026-10-02 14:17:09 +07:00
tiennm99 fb0f6dc68e docs(plans): record the thuyvan flood alert research, plan and journal 2026-10-02 13:06:08 +07:00
tiennm99 053fd07e5a feat(weather)!: add /thuyvan flood alerts for Tân Thuận
/thuyvan shows the 5-day tide-peak forecast at Phú An and Nhà Bè from
the Đài KTTV Nam Bộ bulletin PDF, the Open-Meteo rain forecast, and live
VNDMS river gauges within 30 km. /thuyvan_subscribe opts a chat into a
10:30 ICT push, retried at 12:30, sent only when a forecast peak reaches
báo động I (1.40 m) or a day's rain reaches 50 mm. A missing or stale
bulletin fails the push instead of reading as no risk.

The thoitiet module is renamed to weather; the /thoitiet* commands keep
their names.

BREAKING CHANGE: the module key is now "weather". A MODULES list that
names thoitiet fails at startup and must name weather instead.
2026-10-02 13:06:07 +07:00
tiennm99 59c74de9a9 refactor(lol): move the subscriber list and daily push fan-out into a shared package
The subscriber store, the once-per-day claim, the terminal-error
classifier and the throttled fan-out with dead-chat pruning now live in
internal/modules/util/subscription so another module can offer an
opt-in daily push. Stored document shapes and keys are unchanged.
2026-10-02 13:06:07 +07:00
tiennm99 53f387c576 feat(random): add an unlisted /genshin meteor wish command
/genshin takes the same input as /gacha and sends the Genshin-style
meteor wish that wheelofnames serves on /api/genshin, as a 7-second
640x360 MP4. It stays out of the command menu and /help.
2026-10-01 23:38:21 +07:00
tiennm99 59f597016b feat(genshin): serve the Remotion meteor wish on /api/genshin
Brings back the Genshin-style meteor wish that /api/gacha rendered before pack-cards, under genshin names. It takes the same request as /api/gacha and returns the 7-second landscape MP4.
2026-10-01 23:37:27 +07:00
tiennm99 b7a4336a7b chore(gacha): drop the last references to the retired beta route
Removes the test that checked /api/gachabeta answers 404 and the journal for the old beta animation, and points the research reports at /api/gacha.
2026-10-01 23:27:56 +07:00
tiennm99 48a30863c3 fix(gacha): stop the card text moving after the card settles
Three things moved the text once the card looked still:

- The spin's last degrees: Chrome draws text on a slightly tilted card
  differently from a flat one, so glyphs hopped as the spin ran out. The
  spin now lands flat a moment before the card stops gliding.
- The landing: the card flew at its rounded size scaled to a fractional
  layout size, then was laid out again, shifting the text. The card's
  size is now whole pixels.
- The last flight frames: a sub-pixel creep at almost-identity scale drew
  the text differently from the card at rest. The flight now reaches its
  final pose slightly early.
2026-10-01 23:22:51 +07:00
tiennm99 949e5e57ec revert(gacha): restore the original pack tear and drop motion
Only the card rising out of the pack keeps its exponential easing; the scripted tear and the pack falling away move as pack-cards drew them.
2026-10-01 23:22:51 +07:00
tiennm99 76c96464a8 feat(gacha): ease the tear, pack drop, and card rise exponentially
The scripted drag across the seal, the torn pack falling away, and the
card rising out of it now ease exponentially in and out. The pack keeps
pack-cards' 1.35s drop and hold, and the card still clears the pack when
the spin starts.
2026-10-01 22:52:52 +07:00
tiennm99 9e3cf9d78a fix(gacha): park the pointer off the card after tearing the pack
The scripted drag released the mouse over where the card lands. Once the
card arrived under it, pack-cards tilted the hovered card toward the
pointer over its .22s transition, so the face kept moving after the spin
stopped. The pointer now moves to the corner when the drag ends.
2026-10-01 22:43:05 +07:00
tiennm99 9d5a207f54 fix(gacha): keep the polychrome engraving on the card while it flies 2026-10-01 22:34:05 +07:00
tiennm99 6a4d0a1abb feat(gacha): draw the polychrome on the card engravings only
The sliding rainbow sheen over the whole face and the spectrum foil are
gone. 4★ cards are now engraved with pack-cards' epic contour and 5★ cards
with the legendary rings or facets, and those engraved lines carry a still
rainbow; 3★ cards stay plain.

The warm-up also renders a 4★ wish, since the first card with the contour
engraving otherwise rendered about seven seconds slower.
2026-10-01 22:26:37 +07:00
tiennm99 9665a3b47b docs: describe the shipped miti99bot wheel integration 2026-10-01 22:15:17 +07:00
tiennm99 fafe61b28b feat(random)!: send /gacha as the portrait card-pack wish
wheelofnames now renders /api/gacha as the 6-second portrait card-pack
wish and no longer serves /api/gachabeta. /gacha sends it at 360x640, and
the unlisted /gachabeta command and the per-style renderer settings are
removed.

BREAKING CHANGE: /gachabeta is gone; /gacha needs a wheelofnames build that
renders the portrait wish.
2026-10-01 22:13:59 +07:00
tiennm99 7ca1468a43 feat(gacha)!: render /api/gacha as the pack-cards wish
/api/gacha now renders the 6-second portrait pack-cards opening that was
served on /api/gachabeta. The Remotion meteor wish, its timeline, and the
beta route are removed, and the pack-cards renderer and page take the plain
gacha names.

BREAKING CHANGE: /api/gachabeta is gone, and /api/gacha returns a 6-second
portrait video (360x640 for width 640, 480x854 for width 854) instead of the
7-second landscape one.
2026-10-01 22:12:25 +07:00
tiennm99 d657c49c83 feat(gacha): give the beta card a polychrome rainbow foil 2026-10-01 22:07:12 +07:00
tiennm99 eb4208f1df feat(gacha): spin the beta card after it clears the pack, with sparkles
The card now rises out of the pack at pack-cards' own speed and only starts spinning once it is clear. The rest of the flight is stretched so three and a half turns ease out smoothly before the card lands face up, and glowing sparkles in the rarity colour burst around it while it spins. pack-cards is unchanged; the page reshapes its flight animations.
2026-10-01 19:03:31 +07:00
tiennm99 6f7fff5aab feat(random): send /gachabeta clips as 360x640 portrait animations
Each gacha style now carries the frame size it renders, so Telegram receives the beta wish's portrait size while /gacha stays 640x360.
2026-10-01 18:45:08 +07:00
tiennm99 fc3c7ae0c0 feat(gacha): render the beta wish in portrait
/api/gachabeta now returns a portrait video with the requested width as its long edge: 640 renders 360x640 and 854 renders 480x854. The request body is unchanged, and /api/gacha stays landscape. The pack sits higher so it stays in frame while the card rises.
2026-10-01 18:45:06 +07:00
tiennm99 b73347bb4f feat(gacha): spin the beta card one and a half turns as it is revealed
pack-cards reveals the card with a half turn from rotateY(180deg) to 0deg. The page starts that one animation at 540deg, so the card spins further before landing face up, without changing the library.
2026-10-01 18:45:06 +07:00
tiennm99 f9526c4613 fix(random): match the /gachabeta clip length to the 6-second beta wish 2026-10-01 18:22:32 +07:00
tiennm99 f44b8564eb docs(plans): record research on open-source gacha animation projects 2026-10-01 18:22:30 +07:00
tiennm99 8c06fdf498 feat(gacha): render the beta wish as a pack-cards pack opening
/api/gachabeta now tears open a pack-cards collectible pack whose card
shows the request's label, the same B/A/S rank as /api/gacha, and one
star per rarity level; rarity also picks the card material and the pack
colour. pack-cards animates on the browser clock, so the route drives a
shared headless Chrome in deterministic mode over a debugging pipe,
steps virtual time frame by frame, tears the pack with a scripted drag,
and encodes the captured frames with Remotion's bundled ffmpeg. The
page and package are served from disk with all other requests blocked.

The browser stays alive per process and is warmed at start-up, because
the first render compiles the pack's WebGL shaders in software. The
Remotion beta composition and its scene code are removed. pack-cards is
installed from a GitHub tarball pinned to a commit, since it has no npm
release and a moving URL would break npm ci.
2026-10-01 18:22:30 +07:00
tiennm99 25eb1414d4 fix(random): describe /gachabeta generically and match its new length
The beta renderer changed style, so /gachabeta now reads "Gacha (beta
version)" in its command description, package doc, README row, and deploy
guide instead of naming one look. The clip length sent to Telegram is 11
seconds to match the renderer's 10.5-second beta animation.
2026-10-01 16:44:16 +07:00
tiennm99 f75c0e560b fix(gacha): show the requested rank and stars on the beta reveal
The beta splash card used a fixed SSS rank and six stars. It now uses the
same rarity info as /api/gacha: the rank letter (B, A, or S) on the emblem
and name plate, and one star per rarity level beside the caller's label.
2026-10-01 16:36:22 +07:00
tiennm99 081ab00f2e feat(gacha): restyle the beta wish as a six-star wish sequence
The beta wish now cuts like an anime gacha game's six-star wish over 10.5
seconds: a dive through a painterly cloud vortex, a beam pass, a meteor
that stops inside a rainbow halo and bursts, a red flash, falling crystal
comets joined by a red meteor, a black emblem silhouette shedding shards
on a red disc, and a splash card with a name plate and six stars.

Cloud layers are painted once per roll onto a canvas with a seeded noise
displacement, so frames only move the baked image instead of running a
live SVG filter. The /api/gachabeta contract is unchanged.
2026-10-01 16:32:30 +07:00
tiennm99 b8d5544fd6 feat(gacha): rebuild the beta wish as a meteor shower reveal
The beta wish now renders an 8-second night sky over a snow-rimmed
mountain ridge, where coloured meteors glide down the diagonal. A hero
meteor in the rarity colour slows and burns out at the centre, where the
label appears under rank SSS. The toon cloud, comet, and perspective
camera are removed. The /api/gachabeta contract is unchanged.
2026-10-01 16:07:46 +07:00
tiennm99 adea684a2f feat(gacha): refine beta wish animation and SSS reveal 2026-10-01 15:42:54 +07:00
tiennm99 11f458fee5 feat(random): group the random pickers into a random module and add /gachabeta
/random, /wheelofnames, and /gacha move from misc into a new random module
with unchanged command names, so usage stats carry over. The new unlisted
/gachabeta takes the same input as /gacha and renders the toon-shaded beta
wish from /api/gachabeta on the same service, with the same text fallback.
2026-10-01 15:17:11 +07:00
tiennm99 577c0e3a70 feat(modules): add an unlisted command visibility
Unlisted commands can be run by anyone like public ones but never appear
in /help or the Telegram command menu.
2026-10-01 15:17:11 +07:00
tiennm99 d2f09ecb33 feat(gacha): add /api/gachabeta toon-shaded wish style
The beta wish renders an 8-second astrology-themed night sky in toon
shading through a perspective action camera: it dollies toward a hero
cloud, a comet lights the cloud's rim from behind and bursts through it,
the camera chases the comet as its flare builds to a starburst, and the
label appears with an S, SS, or SSS rank. It shares the /api/gacha request
contract and render slots.
2026-10-01 15:07:56 +07:00
tiennm99 dccd524f9d feat(gacha): fly into the rank emblem with a rainbow sunburst and twinkling sky
The meteor now flies in from the upper left and lands on the rank emblem,
so the impact burst becomes the badge reveal. The 5-star rainbow ring is
replaced by a rainbow sunburst of counter-rotating rays. Sky stars twinkle
with short bright flares and cross glints, and each roll draws a fresh
layout from a per-request seed that the route picks when the caller sends
none.
2026-10-01 14:43:46 +07:00
tiennm99 9b93c53647 revert: restore the gacha animation to bd314b4
Reverts be27792 (measured straight glide) and 028ad08 (gravity arc), going
back to the B/A/S tier-escalation version with the original curved fall.
2026-10-01 14:26:33 +07:00
tiennm99 e158319981 docs(plans): record the measured meteor curve from reference footage 2026-10-01 14:12:03 +07:00
tiennm99 dc8f7f6ae7 feat(gacha): match the meteor glide to the source game's motion
Measured from reference footage, the meteor sweeps in along a straight line
from the upper left at about 23 degrees and eases out exponentially to a
hover above the horizon, replacing the gravity arc. The trail is now a soft
beam that narrows into the head with ribbons fanning along it, every tier
gets the horizontal lens flare, and sparkles drift above the beam.
2026-10-01 14:12:01 +07:00
tiennm99 4fa0e10384 docs(plans): record gacha research and the rarity prefix format 2026-10-01 13:59:12 +07:00
tiennm99 5561c33068 feat(misc): default /gacha options to 5 stars with a 3*/4* prefix
Rarity is now a leading prefix such as "4* Pho" or "3* Rice"; options
without one are 5 stars. This replaces the trailing "*5" tag and its
3-star default.
2026-10-01 13:59:12 +07:00
tiennm99 004325d947 feat(gacha): make the meteor fall under real gravity
The meteor now follows a ballistic parabola: constant horizontal speed and
vertical speed growing under gravity solved from the launch and impact
points, so it climbs slightly, bends over, and dives while speeding up. The
trail samples past positions in time so it lengthens with speed, the head
stretches along its velocity, and sparks inherit the meteor's velocity and
fall under their own gravity.
2026-10-01 13:58:38 +07:00
tiennm99 adc2b94ae9 feat(gacha): escalate the wish animation by rarity with B/A/S rank emblems
Every beat now scales from a per-tier effects table: 4-star adds a bigger
meteor, lens flare, impact shake, and double shockwave; 5-star adds a
rainbow halo before landing, a gold sky flood, a starburst, counter-rotating
rays, falling sparkles, and an emblem sheen. The emblem shows the rank
letter instead of the label's first character.
2026-10-01 13:25:06 +07:00
tiennm99 b3c46a2f2f docs(plans): note the switch to uniform gacha odds 2026-10-01 13:09:08 +07:00
tiennm99 4816f71fe3 feat(misc): give every /gacha option an equal chance
The rarity tag now only styles the wish animation and the result text, so
/gacha picks like /random.
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 858eaa066b feat(misc): add /gacha Genshin-style wish picker
Options take an optional *4 or *5 rarity tag; untagged options are 3 stars.
A pick rolls a tier at Genshin base rates over the tiers present, then an
option uniformly within it, so untagged input matches /random. The wish
animation renders as MP4 on the wheelofnames service at /api/gacha, with a
text fallback when the renderer is unset or fails.
2026-10-01 13:02:46 +07:00
tiennm99 53ca1e59b3 refactor(misc): share the renderer POST and placeholder helpers 2026-10-01 13:02:46 +07:00
tiennm99 97d8aeaadf feat(gacha): add /api/gacha wish animation rendered as MP4
A meteor coloured by rarity falls across a night sky, lands in a white
flash, and the label is revealed with its stars popping in. The caller
picks the result and rarity; the route only draws it. Renders as silent
H.264 so Telegram plays it as an animation without GIF banding, and
shares the render slots with /api/gif.
2026-10-01 11:43:42 +07:00
tiennm99 61d0e74709 refactor(render): share the render core and bearer check across routes 2026-10-01 11:43:42 +07:00
tiennm99 2fd362dd01 feat(thoitiet): make /thoitiet an hourly forecast for the next 6 hours 2026-10-01 10:34:03 +07:00
tiennm99 f748d34095 feat(thoitiet): add today, tomorrow, and 7-day weather commands 2026-10-01 10:28:10 +07:00
tiennm99 4607ae5f14 feat(help): split /help into several messages past Telegram's length limit 2026-10-01 10:28:09 +07:00
tiennm99 7cc0ead54f refactor: remove the unused gold spot-price chain and lol ErrEmptyResult 2026-09-30 14:20:38 +07:00
tiennm99 897e37ba80 fix(storage): return Mongo List keys in key order like the memory store 2026-09-30 14:20:27 +07:00
tiennm99 00eccd5f5b fix(stats): clear a moved username from its previous holder's rows 2026-09-30 14:20:08 +07:00
tiennm99 fdd8f6af44 fix(server): wait for polling and the final metrics flush on shutdown 2026-09-30 14:19:49 +07:00
tiennm99 1d42887d6b fix(server): return 500 when a handler panics before writing 2026-09-30 14:19:39 +07:00
tiennm99 83a5b8135f fix(gold): switch to a managed VNAppMob key when the env key is rejected 2026-09-30 14:19:30 +07:00
tiennm99 72b2b21912 fix(wordle): stop giveup from recording a second loss on exhausted rounds 2026-09-30 14:19:20 +07:00
tiennm99 cf620a257e docs: fix stale code comments and add missing package docs
Correct comments that described the retired webhook transport, a removed
/cron route, the old KV store and wrapper types, and behaviour that has since
changed; drop plan and review labels; reword two startup log lines that
overstated or misnamed what they report.
2026-09-30 14:19:01 +07:00
tiennm99 d51bcbd224 docs: correct README, deploy guide and module docs against current code 2026-09-30 14:17:31 +07:00
tiennm99 97aaba83ec feat(misc): add /giaxang Petrolimex retail fuel prices 2026-09-30 11:44:42 +07:00
tiennm99 749cd516a3 feat(lol): drop EMEA Masters and CBLOL from the schedule allowlist 2026-09-21 08:47:32 +07:00
tiennm99 26c9431331 build: stop publishing the host port under Coolify
Coolify attaches the container to its proxy network and routes to the exposed
port, so the host publish was redundant and additionally reachable on
0.0.0.0:3000 outside TLS. Left commented for standalone `docker compose up`.
2026-09-19 16:32:45 +07:00
tiennm99 53e569ca12 feat(blacklist): add /blacklist shorthand and /whitelist_rnd
/blacklist is the short form of the two read commands: bare it lists both
lists like /blacklist_rules, and given text it judges that text like
/blacklist_check. Both long names stay for when the intent should be
explicit. An argument of only whitespace is not an argument, so it lists.

/whitelist_rnd returns one whitelist entry chosen at random, and says the
list is empty rather than answering with nothing. It reads only the
whitelist, and only for the calling topic.

The six existing command descriptions are shortened alongside. /help
renders as one un-chunked message pinned at 4096 runes, and two more
commands pushed it to 4123; the shorter wording brings it to 4049. That
ceiling is a shared limit this module did not create, and it will bind again
on the next command added anywhere in the repo.
2026-09-15 13:58:45 +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 d2272bd84d fix(alias): tell an unreadable reply apart from an unsupported one
Replying to another bot's message and running /alias answered with the
list of kinds that can be saved, which blames the format of a message
the bot was never shown — it may well have been a photo. Telegram strips
the content of another bot's message, and the sender is not always marked
as a bot: an anonymous or service-posted message arrives equally empty,
so the existing bot check missed it.

Judge on whether any content arrived instead. A reply with no content
field at all, and a command that arrives with no reply attached, now both
say what happened and end with the one action that works: forward the
message into the chat and reply to your own copy. A reply that did arrive
with content of a refused kind — a poll, a location — still gets the list
of supported kinds.

One contentFields table backs both the check and the alias_capture debug
line, so the log line always explains the refusal the caller was given.
2026-09-08 16:20:35 +07:00
tiennm99 67836f0cf5 test(amlich): pin the golden fixture to LF
The table is compared byte-for-byte against output generated with \n, so
a Windows checkout with core.autocrlf=true rewrote the committed fixture
to CRLF and failed the comparison on that machine only — passing in CI,
and printing a diff whose two sides looked identical.

The pattern matches text extensions under testdata only: forcing text
conversion on a binary fixture would corrupt it.
2026-09-08 16:20:35 +07:00
tiennm99 8260d2860b fix(alias): answer inline queries from one store read under a deadline
Telegram expires an inline query and then rejects the answer with "query
is too old and response timeout expired or query ID is invalid". The
picker invited that: it listed the names and then read the store once per
name — up to 50 round trips per keystroke — and it was the only handler
in the module with no deadline of its own. Updates are dispatched one at
a time, so a single slow answer also held up the queries queued behind
it, each ageing while it waited, and one slow read expired a whole burst
of typing.

Add DocStore.Scan, which reads a key prefix with its values in one round
trip, ordered by key. The picker and /aliases both use it, so neither
grows a round trip per saved alias. Bound the inline handler at 3s: an
answer later than that is rejected anyway, and giving up frees the worker
for the fresher query behind it. When Telegram does reject an answer, the
error now carries how long it took, which separates a slow handler from a
query that was already stale on arrival.

The 50-result cap now counts results the picker can show, so a video-note
alias — which has no cached inline type — no longer consumes a slot.
2026-09-08 16:02:39 +07:00
tiennm99 cb86ec4cf3 feat(alias): log the shape of a capture at debug level
The capture failures worth debugging are all about what Telegram did not
deliver: a reply stripped of its content, a caption where text was
expected, or a kind that falls through the switch. None of that is
visible from the user-facing refusal, which only says "unsupported".

One alias_capture line per /alias reports field names, lengths and
counts — never message text, so aliased messages cannot travel with the
logs.
2026-09-08 16:02:18 +07:00
tiennm99 2a86e028f6 feat(alias): keep text formatting, name kinds in /aliases, copyable commands
Text and caption formatting now survives an alias. Bold, italic, code, links
and mentions are stored as entities beside the text and sent back with it, on
the /insert, bare-command and inline paths alike. This works because the text
is re-sent byte-identical, so the offsets the entities carry stay valid — the
earlier comment claiming otherwise was wrong. They go back as entities rather
than re-rendered markup, which avoids escaping and re-parsing content the user
never wrote as markup.

/aliases now lists one line per name with what it holds, so the list says what
each will send:

    3 aliases:
    /cheer — sticker
    /clip — video
    /greeting — text

That costs one store read per listed alias, since DocStore has no bulk get and
the kind lives in the document. The reads stop once the message is full, so the
cost is bounded by what fits in one reply rather than by how many aliases exist.

Every reply that names a command or an alias wraps it in <code>, so tapping it
copies something ready to send. The generic usage lines stay plain text: they
contain a literal <name> placeholder that HTML mode would swallow as a tag.

Replying to another bot's message gets its own refusal. Telegram delivers that
reply with the content stripped, so capture finds nothing and the format advice
read as if the wrong kind had been sent. Checked only after capture fails, so
this bot's own messages — which are readable — never reach it.
2026-09-04 13:28:50 +07:00
tiennm99 eafeebb69c fix(telegram): allow inline_query through the getUpdates filter
The alias module registers an inline-query handler, but pollingAllowedUpdates
listed only message and callback_query. Telegram filters getUpdates on its
side, so inline queries were dropped before reaching the bot — the handler
never ran, and the omission left no log line or error to debug from.

Adds a test tying the list to the kinds actually handled, since nothing else
would catch the next such gap.
2026-09-04 11:46:59 +07:00
tiennm99 b903d14fc2 chore(sticker): drop the retired per-user pack records at startup
The module stores nothing: /addsticker takes its pack from STICKER_PACK_NAME
and the set owner from OWNER_ID, and the factory ignores the collection it is
handed. Everything still in the sticker collection is therefore unreachable by
any code path — pack documents keyed by owner ID, "slug:" name reservations and
"pending-delete:" confirmations, all orphaned when the per-user commands were
removed.

InitStore lists and deletes them once per database, guarded by a systemstate
marker in the same shape as the stock and stats migrations. It aborts without
writing the marker so a partial run retries on the next boot, and deletes are
idempotent. A collection that is already empty is the normal case on a fresh
deploy and on the memory backend.

This permanently removes data. Back up the sticker collection before the first
deploy that carries it.
2026-09-04 11:37:29 +07:00
tiennm99 be91d2eb41 feat(alias): add a shared alias dictionary invocable as a bare command
/alias <name> saves a replied message under a name and /insert <name> sends it
back; /aliases lists every name and /unalias deletes one. Every Telegram format
is supported — sticker, photo, GIF, video, video note, audio, voice, document,
plain text — and each is kept as the file_id Telegram already issued, so nothing
is downloaded and an alias survives redeploys. The namespace is global and the
last assignment wins, matching the shared sticker pack; /unalias is open to
anyone for the same reason.

A saved name also works as its own command: /cheer rather than /insert cheer.
This needs two new seams in the module contract. Module.Fallback handles a
/command no module registered, and the dispatcher installs it after every
Command — the bot library returns the first matching handler, so code always
beats a name resolved at runtime, including an alias that shares a command
added in a later build. /alias refuses a name already in the registry for the
same reason, since such an alias would only reach /insert. An unknown command
stays silent: the fallback sees every unrecognised /foo in every chat, so
replying would make typos noisy and would confirm which names exist.

Module.Inline answers inline-mode queries — "@botname <prefix>" from any chat,
filtered by prefix and capped at Telegram's 50 results. Each result is a cached
inline type carrying the stored file_id, so the picker renders real previews
without an upload. Video notes are omitted because Telegram defines no
InlineQueryResultCachedVideoNote and substituting a plain video would change
what was saved. Inline mode must be enabled in BotFather before Telegram
delivers these updates.

Both slots are single-occupancy with conflict detection at Build. Auth.Permits
learns the inline sender so a gated inline handler would not deny everyone.
Build's command indexing and slot claiming move into addCommands/addSingletons,
keeping it under the project's cyclomatic cap.

Also restores the sticker module: /addsticker moves back out of util, which has
no store, into internal/modules/sticker as its only command.
2026-09-04 11:36:50 +07:00
tiennm99 822bcdbca8 fix(util): justify gosec G204/G304 on the ffmpeg transcode 2026-09-04 10:40:25 +07:00
tiennm99 b81966469e docs: allow committing directly to main in this repo 2026-09-04 10:36:38 +07:00
tiennm99 2503353e68 feat(util): replace per-user sticker packs with one shared, self-creating pack
/addsticker becomes a single stateless command in util, writing to one
env-configured set (STICKER_PACK_NAME, default miti99_by_miti99bot).
AddStickerToSet takes the set owner's user ID rather than the caller's, so
nothing is per-user any more: the sticker module's pack records, slug
reservations, pending deletes, per-user locks and its eight other commands are
removed with it.

The pack creates itself on first use. A positive STICKERSET_INVALID from the
add triggers createNewStickerSet owned by OWNER_ID, seeded with the sticker
that triggered it and titled with the slug half of the name; a name that is
occupied but unwritable is reported instead of taken over. The mandatory
"_by_<bot_username>" suffix is Telegram's own proof of authorship, so a
misconfigured pack name is refused offline before any API call. StickerSet
exposes no owner ID, so ownership is only provable when Telegram refuses.

Video, GIF, animation and video-note sources are transcoded to WEBM/VP9 with
ffmpeg: long edge scaled to exactly 512 in either direction, cut to 3s, capped
at 30fps, audio dropped, retried down a CRF ladder until under 256KB. Animated
and video stickers are copied by file_id with no conversion. Sticker format is
per-sticker since Bot API 7.2, so one pack holds all three.

ffmpeg cannot ship in distroless/static and Go has no VP9 encoder, so the
runtime base becomes alpine with apk add ffmpeg. The image grows from roughly
20MB to 213MB, and the transcode holds the single dispatcher worker — bounded
at 20s per encode and a 45s handler deadline for moving sources, against 10s
for stills.
2026-09-04 10:34:29 +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 44c44e5d00 feat(misc): add /xlt1 đơn xin lỗi T1 petition template
Sequel to /ff: the same fan who panicked and surrendered mid-series now
files an administrative petition because the team came back. Punchlines
are quoted back as the sender's own words and retracted rather than
asserted fresh.

Shaped as a Vietnamese đơn từ parody — quốc hiệu, Kính gửi, Nội dung sự
việc, Tôi xin cam kết, signature block — since the joke is the
bureaucratic form, not the wording. Carries no scoreline or title count
so it does not go stale next split.

Reuses senderMention for the Tôi tên là field and the signature, which
made the trongTruongHopUpdate test helper name wrong; renamed it
messageFrom now that two command families share it.
2026-09-02 16:03:20 +07:00
tiennm99 eebd034fbc feat(misc): hold the wheel spin with a placeholder message
A remote wheel render takes several seconds, during which /wheelofnames
looked unresponsive. Post "Spinning..." first, then let the result take
its place: the GIF replaces it (send-then-delete, since Telegram cannot
edit text into media) and any render or upload failure edits the same
message into the plain text winner. A rejected edit still falls back to a
fresh reply so the chat never stays stuck on "Spinning...".

No placeholder when no renderer is configured — the winner reply is
already immediate there and would only flash.

Adds SendText/EditText/DeleteMessage to chathelper; Reply now delegates
to SendText.
2026-08-27 14:42:07 +07:00
tiennm99 26a5c41d8e Merge pull request #4 from tiennm99/feature/sticker-pack-module
feat(sticker): sticker pack module
2026-08-25 17:20:14 +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 3751010b8e feat(sticker): add sticker pack module
Nine commands mirroring the names @Stickers uses: /newpack, /mypack,
/addsticker, /delsticker, /editsticker, /ordersticker, /setpackicon,
/renamepack and /delpack, plus a confirm callback for the destructive
one. Sources are replied stickers, photos or image documents; photos are
downloaded, resampled to 512px and re-uploaded.

One pack per user, keyed by owner id. Creating a pack is the only
operation here that makes a durable, publicly linkable object on a user's
behalf, so it is built around proving ownership rather than assuming it:

- A name is claimed globally and create-only before Telegram is called.
  A pending record alone proves only that a caller *asked* for a name,
  which is exactly what someone naming a victim's public slug also does.
- Adopting an existing set additionally requires that the claim predates
  this invocation. The claim lives in our store and the pack lives at
  Telegram, so a wiped store would otherwise make every pack adoptable.
- Names are released only on positive evidence that no pack stands behind
  them, never on a generic failure, so a transient error cannot hand a
  live name to the next caller.
- Ownership refusals are byte-identical across failure modes, so they
  cannot be used to probe which sets exist.

Error classification is positive-only throughout: "the set is gone" and
"nothing was created" are each proven from a specific Telegram response,
never inferred from an error. Post-action commits run on a context
detached from the request so a shutdown mid-handler cannot lose the
record of something Telegram already did.

Enabled explicitly via MODULES rather than by default.
2026-08-25 15:54:28 +07:00
tiennm99 e1bf7f1ddb style(wordle): gofmt lookup test table 2026-08-25 15:54:12 +07:00
tiennm99 19dec60aa6 test(testutil): stub struct results and coded failures in the recording bot
Three gaps made parts of the Telegram API untestable:

- Methods that decode into a struct (getStickerSet, getFile, getMe,
  uploadStickerFile) only ever saw `{"ok":true,"result":true}`, so they
  could return nothing but unmarshal errors. StubMethod supplies a real
  result payload.
- The library classifies errors from the error_code in the response
  body, not the HTTP status, so a codeless failure never took a sentinel
  shape. FailMethodCode emits the code, letting handlers that branch on
  errors.Is be tested at all.
- Parameterless calls send no body, and the unconditional form parse
  rejected them before any stub applied.

The parse tolerance is scoped to an empty body rather than to any parse
failure: multipart reports "no parts" for both an absent body and a
corrupt one, and answering a corrupt request 200 with an empty form
would quietly satisfy tests elsewhere that assert a field is absent.
2026-08-25 15:54:12 +07:00
tiennm99 25b952a9b7 feat(modules): recover panics in command and callback dispatch
The bot runs with WithNotAsyncHandlers and a single worker, so handlers
execute inline on the polling goroutine. A panic in any handler therefore
killed the process and took every user's bot down with it.

Wrap the command closure, the callback closure and the detached command
hook in a recover barrier. The callback path also answers the pending
query so the client stops spinning rather than waiting out its timeout.

The barrier is a backstop, not a licence to skip nil checks: handlers
still guard their own inputs.
2026-08-25 15:54:01 +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 86c52170de feat(amlich): hint ambiguous leap-month input and flag disputed month boundaries
- /duonglich appends a nhuan hint when the queried month is also that
  lunar year's leap month and the exact leap date exists
- both commands append a caveat when the result falls in a lunar month
  starting or ending on one of the seven razor-edge boundaries from
  2072 on (new moon within ~2 minutes of UTC+7 midnight)
- freeze the verified 1800-2199 month structure as golden testdata so
  a self-consistent engine change cannot silently shift boundaries
- close known-issues open questions 1 and 2
2026-08-18 23:00:31 +07:00
tiennm99 f475a5cb46 build: move from pnpm to npm
Replace pnpm-lock.yaml with package-lock.json. allowBuilds for esbuild becomes
package.json#allowScripts. The Dockerfile installs with npm ci --omit=dev and
no longer prepares pnpm through corepack; .npmrc keeps engine-strict, which npm
honours natively.

minimumReleaseAgeExclude is dropped rather than translated: pnpm's
minimumReleaseAge was never set, so the 30-entry exclusion list had nothing to
exclude from. Direct dependency versions resolve identically to the pnpm
lockfile.
2026-08-17 12:30:41 +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 3b99aa9dc9 docs: correct stale module claims and document amlich edge cases
README omitted /lol_subscribe and /lol_unsubscribe, called gold opt-in
though an empty MODULES loads every catalog module, and left out both
the amlich 1800-2199 bound and the lol module's PandaScore token. The
gold factory comment repeated the same opt-in claim.

Promote the lunar algorithm decision record and known-issue list into
docs/ so they survive cleanups of plans/.
2026-08-16 22:47:24 +07:00
tiennm99 d1ef691ff6 docs: add lol schedule research, tgs feasibility reports, and pandascore journal 2026-08-10 10:00:53 +07:00