Commit Graph
95 Commits
Author SHA1 Message Date
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
tiennm99 7a0163ec01 feat: format compact portfolio numbers 2026-07-22 12:27:52 +07:00
tiennm99 7487d21499 fix(stock): note currency in portfolio title 2026-07-22 10:59:40 +07:00
tiennm99 cc7dd4aa10 docs: remove completed implementation records 2026-07-21 19:13:34 +07:00
tiennm99 d14b34d8ce docs(deploy): refresh persisted storage layout 2026-07-21 19:13:28 +07:00
tiennm99 ee2c520944 docs(stock): document dividend event buttons 2026-07-21 18:20:14 +07:00
tiennm99 96ba34384a refactor(portfolio): retire completed migrations 2026-07-21 17:13:19 +07:00
tiennm99 7083079b39 feat(portfolio): nest asset positions 2026-07-21 16:42:33 +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 a211b7f8b7 fix(commands): remove examples from command discovery 2026-07-21 13:52:53 +07:00
tiennm99 b5c7427520 fix(commands): render examples conditionally with inline help code 2026-07-21 13:21:01 +07:00
tiennm99 f5ba9d4032 feat(commands): improve discovery and normalize parameters 2026-07-21 11:45:20 +07:00
tiennm99 3316eec74c test: provision MongoDB 8 with Testcontainers 2026-07-21 09:39:06 +07:00
tiennm99 f0d361eab5 refactor: adopt cross-platform Go development workflow 2026-07-21 09:05:20 +07:00
tiennm99 6ceb7aff02 docs(stock): document dividend commands 2026-07-20 17:33:10 +07:00
tiennm99 6c934f6807 feat(modules): remove world cup module
The 2026 tournament has ended, so the schedule and daily digest commands
have no upcoming matches to report. Drops the module, its catalog entry,
command menu entries, and the WC_FOOTBALL_DATA_TOKEN env var.

Stored subscriber and match-cache documents are left in place.
2026-07-20 11:03:59 +07:00
tiennm99 337b7de18c refactor(stats): remove completed startup migrations 2026-07-09 15:44:23 +07:00
tiennm99 1560d804a7 fix(misc): fall back wheelofnames to text reply 2026-07-09 14:38:04 +07:00
tiennm99 3646f9c02c feat(misc): promote wheelofnames gif command 2026-07-09 13:14:11 +07:00
tiennm99 00dc4c7800 feat(misc): add wheelofnamesbeta render API 2026-07-07 00:12:58 +07:00
tiennm99 9308358a23 docs: record wheelofnames implementation 2026-07-03 11:12:49 +07:00
tiennm99 5254ab4c8e refactor: remove completed startup migrations 2026-07-01 11:42:59 +07:00
tiennm99 364ddee285 feat(stats): migrate command history 2026-07-01 11:10:36 +07:00
tiennm99 ccaa34336d feat: rename lol schedule module 2026-07-01 10:38:49 +07:00
tiennm99 8f0286e54f feat(stats): use queryable Mongo usage records 2026-07-01 10:22:03 +07:00
tiennm99 9c998a0e2f refactor(modules): remove twentyq genai integration 2026-06-29 10:07:21 +07:00
tiennm99 20dc03c6d2 refactor(config): remove price URL env overrides 2026-06-29 09:23:20 +07:00
tiennm99 5aa538be96 feat(server): register command menu on startup 2026-06-29 01:08:51 +07:00