81 Commits
Author SHA1 Message Date
tiennm99 629b513c91 feat(games): move /wordle and /loldle to the web games and add /loldledaily
/wordle and /loldle now send the wordle and loldle BotFather games in an
unlimited mode: each player gets their own word or champion, saved after
every guess, with a New game button. Cards the bot did not record as
unlimited, including the daily cards, play the daily puzzle. The helper
commands keep working on the player's round.

/loldledaily mirrors /wordledaily: one champion per day at 07:00 ICT,
subscribe and unsubscribe, the morning recap and the live group results.

The daily and unlimited machinery is shared in util/guessgame, the old
in-chat wordle module is removed, and a one-time startup migration carries
each player's stats and unfinished round over without changing old data.
2026-10-10 09:29:48 +07:00
tiennm99 c0231ceadf feat(wordledaily): add the daily Wordle HTML5 game
/wordledaily sends a BotFather game card whose page plays one shared word
per day, changing at 07:00 ICT. The server checks guesses, saves each
player's board after every guess and keeps NYT-style stats. A win is
reported with setGameScore, and in a group one live results message shows
everyone's colour grids.

/wordledaily_subscribe and /wordledaily_unsubscribe opt a chat or topic in
to the 07:00 push: yesterday's recap, opening with the answer and the
group's win streak, then today's card.

The HTML5 game plumbing that noitu used is now shared in util/htmlgame, and
the wordle word list and scoring live in wordle/wordlist.
2026-10-09 17:20:02 +07:00
tiennm99 d84bc3d1fb feat(noitu): make /noitu the group game and add /noitutop
/noitu now posts the group play card that /noitupvp used to, and the solo
game against the bot moves to /noitubot. In a private chat /noitu points
the user to a group, or to /noitubot.

/noitutop ranks a group's players across all of its cards by wins, best
game and games played.

A one-time startup migration moves /stats history to the new names in
order: noitu to noitubot first, then noitupvp to noitu.
2026-10-09 16:22:54 +07:00
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 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 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 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 f748d34095 feat(thoitiet): add today, tomorrow, and 7-day weather commands 2026-10-01 10:28:10 +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 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 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 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 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 7312dcf7ea feat(amlich): add Vietnamese lunar calendar conversion module
/amlich converts duong lich to am lich (defaults to today, Asia/Saigon);
/duonglich converts am lich to duong lich with a nhuan flag for leap
months. Dates accept d, d/m, or d/m/yyyy - missing parts fill from today
in the input's calendar. Conversion is a dependency-free port of Ho Ngoc
Duc's algorithm at UTC+7, with rejection of impossible lunar inputs,
anchored by known Tet/leap-month dates and a 1950-2050 round-trip test.
2026-08-08 22:40:03 +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 f9d1e52f6d fix(stock): retire dividend command and migrate stats 2026-07-22 18:55:57 +07:00
tiennm99 df240a5db0 feat(stock): persist per-user dividend history 2026-07-22 16:17:29 +07:00
tiennm99 5980c384c7 refactor(coin): retire completed startup cleanup 2026-07-21 19:13:23 +07:00
tiennm99 01efb83df7 fix(coin): remove stale dividend cursor fields 2026-07-21 18:50:30 +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 0dd93e3bf9 feat(portfolio): track stock and coin cost basis 2026-07-21 15:52:05 +07:00
tiennm99 f0d361eab5 refactor: adopt cross-platform Go development workflow 2026-07-21 09:05:20 +07:00
tiennm99 8b2ac571d9 feat(stock): add ratio dividend commands 2026-07-20 17:32:37 +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 194caf17eb fix(lol): rename next week command 2026-07-06 20:46:10 +07:00
tiennm99 2fb10ff632 refactor: remove completed migration logic 2026-07-01 16:20:30 +07:00
tiennm99 b0b4321fa1 feat(lol): expire match cache 2026-07-01 13:40:37 +07:00
tiennm99 e6d5bc3cc9 feat: refine command names and behavior 2026-07-01 13:19:42 +07:00
tiennm99 5254ab4c8e refactor: remove completed startup migrations 2026-07-01 11:42:59 +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
tiennm99 5971347a6b feat(wc): add world cup schedule module 2026-06-29 00:59:30 +07:00
tiennm99 b0586903f5 refactor(deploy): remove retired aws support 2026-06-29 00:11:42 +07:00
tiennm99 ed867f33f0 feat(deploynotify): notify owner on every startup, no SHA dedup
Match the store-scraper-bot behaviour: DM the owner once per boot regardless of
commit, and report an unknown SHA rather than staying silent. Drop the store-
based dedup and the empty-SHA skip; resolveCommitSHA defaults to "unknown".
2026-06-28 23:25:07 +07:00
tiennm99 0abd33cb42 fix(deploynotify): read commit SHA from Coolify SOURCE_COMMIT runtime env
Baking SOURCE_COMMIT as a Docker build arg never worked: Coolify exposes it
as a runtime env var, not a build arg, so gitSHA was always empty and the owner
DM was skipped. Read SOURCE_COMMIT from the container env at startup (falling
back to the ldflags-baked gitSHA for local builds), forward it via the compose
environment, and drop the dead build-arg baking.
2026-06-28 23:08:17 +07:00
tiennm99 9d5b774846 refactor(server): delete webhook once on startup, no retry
The Bot API GET reliably clears the webhook, so the retry loop added for the
empty-body POST is no longer needed. One best-effort call; log on failure.
2026-06-28 22:52:55 +07:00
tiennm99 36cb5750e6 fix(telegram): clear webhook via Bot API GET to avoid empty-body POST
go-telegram's DeleteWebhook posts an empty multipart form (DropPendingUpdates
is omitempty); some networks answer that bodyless POST with an empty response,
failing to parse and leaving the webhook active so getUpdates 409s forever.
Replace it with a plain GET /deleteWebhook, which sends no body and parses the
JSON reliably. Keeps pending updates for a lossless cutover.
2026-06-28 22:41:49 +07:00
tiennm99 60a7aa5836 fix(server): retry webhook clear on startup past transient empty-body error
Telegram's edge can return an empty body on the first request after a cold
container start, failing the single best-effort DeleteWebhook with 'unexpected
end of JSON input' and leaving a stale webhook that 409s getUpdates. Retry up
to 5 times with a 1s backoff so long polling is reliably unblocked.
2026-06-28 22:33:34 +07:00
tiennm99 d4f1bbfc83 fix(server): clear Telegram webhook at startup before owner DM
Move DeleteWebhook ahead of the deploynotify owner DM and polling so the
webhook is cleared first thing once the bot is ready, instead of just before
getUpdates. DropPendingUpdates=false keeps the lossless cutover.
2026-06-28 22:18:28 +07:00