Commit Graph
123 Commits
Author SHA1 Message Date
tiennm99 6f30d13b2d feat(noitu): add the noitu HTML5 Telegram game
Serve the BotFather game noitu from the bot's HTTP server under
/games/noitu when GAME_BASE_URL is set. /noitu sends the game, Play
answers with a signed, expiring link, and the server validates every
word against the embedded noitu dictionary (CC BY-SA 4.0), runs the
bot opponent and turn timer, and reports the score with setGameScore.

Modules can now register game-short-name callbacks and HTTP routes.
2026-10-09 14:32:57 +07:00
tiennm99 5e96490254 refactor: remove completed one-time startup migrations
Production data is migrated and every marker is recorded in the system
collection, so the stock dividend-history, sticker legacy-pack and stats
stock_dividend migrations no longer do anything. Stats keeps its startup
index creation and still hides retired stock_dividend rows.
2026-10-08 10:35:21 +07:00
tiennm99 7008a57cf5 refactor!: rebrand miti99bot to tiennm99bot
BREAKING CHANGE: the Go module path is now github.com/tiennm99/tiennm99bot,
the default sticker pack is stickers_by_<bot username>, and the health body,
deploy DM, User-Agents and image names say tiennm99bot.
2026-10-07 15:14:02 +07:00
tiennm99 79104ef3d7 feat(config): resolve the bot username from BOT_USERNAME or getMe
The default sticker pack name and the lol User-Agent now follow whichever
bot runs the code, so a bot account change needs no code edit. The default
pack becomes miti99_by_<bot username>.
2026-10-07 15:08:08 +07:00
tiennm99 1c64b44a46 chore(deploy): comment out optional compose variables
Coolify passes every dashboard variable to each service through its
generated .env, so optional values need no compose reference. It also
creates a dashboard entry for every referenced variable and keeps an
existing empty entry over a ${VAR:-default}, which overrides the compose
default. Optional variables are therefore commented-out ${VAR:-default}
lines, and the code supplies their defaults.
2026-10-03 12:46:11 +07:00
tiennm99 e418bf3390 revert: restore code fallbacks for config defaults
This reverts commit 153fc21. With no fallback in code, the deploy
crash-looped: Coolify keeps an entry for every variable compose.yml has
referenced, and empty entries appear to reach the containers as empty
values instead of the compose defaults.
2026-10-03 12:42:16 +07:00
tiennm99 153fc211cf refactor!: move every config default into compose.yml
compose.yml now holds each default as ${VAR:-default}, and the code keeps
no fallback values. The bot stops at startup on a missing or invalid
PORT or LOG_LEVEL, /addsticker refuses without STICKER_PACK_NAME, and the
renderer refuses to start until every RENDERER_* setting is a valid
value, listing each problem. Settings whose empty value means none or
all (MODULES, OWNER_ID, ADMIN_IDS, the API tokens) use ${VAR:-}.

The renderer's npm start and dev load .env when present, so a local run
works from a copy of .env.example.

BREAKING CHANGE: running outside compose now requires LOG_LEVEL and PORT
for the bot, STICKER_PACK_NAME for /addsticker, and every RENDERER_*
variable for the renderer.
2026-10-03 12:38:39 +07:00
tiennm99 be7ad347bd chore(deploy): group compose env vars and list optional ones without defaults
Each service's environment is grouped into required, optional, and
stack-fixed values. Optional variables are referenced without a default,
so Coolify lists them as settings that can be filled in or left empty;
an empty value falls back to the code's default.
2026-10-03 12:24:16 +07:00
tiennm99 9457baf81a fix(deploy): pass LOL_PANDASCORE_TOKEN to the bot explicitly
The lol module reads the PandaScore token, but compose.yml never listed
it, so the bot depended on Coolify passing unlisted variables through. It
is optional: without it the lol commands fail and the rest of the bot
runs.
2026-10-03 12:19:56 +07:00
tiennm99 1ee679ad89 fix(deploy): stop exposing renderer tuning as Coolify settings
Coolify lists every ${VAR} that compose.yml references as an app setting,
so the four renderer tuning values showed up as entries to fill in. They
are no longer referenced: the renderer's own defaults apply, and an
override goes into compose.yml as a literal.
2026-10-03 12:11:25 +07:00
tiennm99 83321fa330 refactor(renderer)!: prefix every renderer env var with RENDERER_
The renderer's settings now read RENDERER_HOST, RENDERER_PORT,
RENDERER_MAX_CONCURRENT_RENDERS, RENDERER_RENDER_TIMEOUT_MS,
RENDERER_MAX_OPTIONS, and RENDERER_MAX_OPTION_CHARS, so they read as
renderer settings and cannot clash with the bot's variables, which
Coolify injects into every service. NODE_ENV keeps its standard name.

BREAKING CHANGE: the unprefixed HOST, PORT, MAX_CONCURRENT_RENDERS,
RENDER_TIMEOUT_MS, MAX_OPTIONS, and MAX_OPTION_CHARS are no longer read
by the renderer.
2026-10-03 12:05:56 +07:00
tiennm99 11ed121af9 fix(deploy): add a compose healthcheck for the bot
Coolify ignores its UI health-check settings for Docker Compose apps and
reads each service's healthcheck instead, so the bot reported an unknown
status. It now checks GET / with the image's busybox wget.
2026-10-03 12:01:30 +07:00
tiennm99 277c913f71 refactor(random)!: configure the renderer by base URL as RENDERER_URL
The bot now takes the renderer's base URL and appends each /api/<name>
route itself, instead of taking the full /api/gif endpoint and swapping
its last path segment for /gacha and /genshin. The client, its files, and
its errors are named after the renderer rather than wheelofnames; the
/wheelofnames command keeps its name.

BREAKING CHANGE: WHEELOFNAMES_API_URL is replaced by RENDERER_URL, which
holds the base URL (e.g. http://renderer:3000) rather than the /api/gif
endpoint. compose.yml sets it, so Coolify needs no value.
2026-10-03 11:52:50 +07:00
tiennm99 2ec4a2b371 refactor(renderer)!: drop the API token from the internal renderer
The renderer now runs only on the compose network with no published port
or domain, so a shared bearer token adds nothing. The renderer no longer
checks Authorization or requires API_TOKEN in production, and the bot no
longer sends a token.

BREAKING CHANGE: WHEELOFNAMES_API_TOKEN and the renderer's API_TOKEN are
removed. Never publish the renderer's port: its API is unauthenticated.
2026-10-03 11:45:26 +07:00
tiennm99 3130f5ec36 docs: describe the bundled renderer service 2026-10-03 11:16:54 +07:00
tiennm99 de46809c96 docs: describe the in-tree monkeyd crawler and record the port plan 2026-10-03 11:23:45 +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 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 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 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 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 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 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 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 d51bcbd224 docs: correct README, deploy guide and module docs against current code 2026-09-30 14:17:31 +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 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 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 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 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 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 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 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
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 af1e8776af feat(monkeyd): export monkeydd.com novels as PDF via /monkeyd_crawl
Add the monkeyd module, which crawls a novel and sends the rendered PDF back
as a Telegram document. Crawling and rendering come from the monkeyd-crawler
submodule, resolved through a go.mod replace directive.

The command is admin-only and restricted to monkeydd.com: one run makes
hundreds of outbound requests over minutes, and the extractor only understands
that site. Exports run one at a time and on a detached goroutine, because
handlers are dispatched synchronously and an inline crawl would block every
other command.

The runtime image gains DejaVuSans; font discovery probes system paths and the
distroless base ships none, so PDF rendering would otherwise fail in
production. CI checks out submodules and the builder copies the submodule
go.mod before go mod download, which needs it to resolve the build list.
2026-07-29 23:07:23 +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 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