docs: correct README, deploy guide and module docs against current code

This commit is contained in:
tiennm99 committed 2026-09-30 14:17:31 +07:00
1 parent 97aaba83ec
commit d51bcbd224
8 files changed
+124 -95

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ MONGO_DATABASE=miti99bot
# Comma-separated module list. Empty = load every module, including any module
# added later — so list them explicitly when a deployment should only gain a new
# module deliberately.
MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker,alias
MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker,alias,blacklist
# Telegram user id for owner-only commands (renamed from BOT_OWNER_ID).
OWNER_ID=
# Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS).
+4 -2
View File
@@ -4,7 +4,8 @@
`miti99bot` is a Go Telegram bot with pluggable modules under
`internal/modules`. Runtime storage is MongoDB when `MONGO_URL` is set and
in-memory storage in tests. Read `README.md` before implementation work.
in-memory otherwise, which is what tests and local no-database runs use. Read
`README.md` before implementation work.
`third_party/monkeyd-crawler` is a git submodule resolved through a `go.mod`
`replace` directive, not a versioned dependency. Go commands fail until it is
@@ -33,7 +34,8 @@ deleting commands, update all related surfaces:
- command parameter metadata used by Telegram and `/help`
- handler usage text and user-facing error text
- tests for registration, handlers, and command menu behavior
- README/docs when behavior changes are user-visible
- the README module table, plus the feature doc under `docs/` when one exists,
when behavior changes are user-visible
Follow `docs/command-parameter-conventions.md` for all command parameter
metadata and usage text. Keep metadata, usage errors, examples, and tests exact.
+39 -19
View File
@@ -7,22 +7,27 @@ Atlas via long polling and an in-process cron scheduler.
| Module | What it does |
|---|---|
| `util` | `/help`, `/info`, `/stickerid` |
| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/xlt1`, `/giaxang`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `util` | `/help`, `/info` (admin), `/stickerid` (owner) |
| `misc` | `/ping`, `/ping_stats` (admin), `/random`, `/wheelofnames`, `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only |
| `wordle` | Daily Wordle game |
| `loldle` | League-of-Legends "guess the champion" |
| `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`), per-chat digest opt-in (`/lol_subscribe`, `/lol_unsubscribe`) + daily push at 08:00 ICT |
| `stock` | VN-stocks paper trading |
| `gold` | Gold paper trading (VNAppMob SJC buy/sell VND/luong) |
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
| `wordle` | Daily Wordle game: `/wordle [word]`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` |
| `loldle` | League-of-Legends "guess the champion": `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats`, `/loldle_setmax` (owner) |
| `lol` | Pro-match schedule (`/lol [date]`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`), per-chat digest opt-in (`/lol_subscribe`, `/lol_unsubscribe`) + daily push at 08:00 ICT |
| `stock` | VN-stocks paper trading: `/stock_price`, `/stock_info`, `/stock_events`, `/stock_topup`, `/stock_buy`, `/stock_sell`, `/stock_cash_dividend`, `/stock_share_dividend`, `/stock_portfolio` |
| `gold` | Gold paper trading (VNAppMob SJC buy/sell VND/luong): `/gold_price`, `/gold_topup`, `/gold_buy`, `/gold_sell`, `/gold_portfolio` |
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback): `/coin_price`, `/coin_topup`, `/coin_buy`, `/coin_sell`, `/coin_portfolio` |
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <username>`, `/stats cmd <command_name>` |
| `sticker` | `/addsticker` — append a replied sticker, image, video or GIF to one shared pack. See [docs/sticker-packs.md](docs/sticker-packs.md) |
| `alias` | `/alias <name>` save a replied message under a name, then send it back with `/insert <name>`, bare `/<name>`, or inline `@botname <prefix>`; `/aliases` lists, `/unalias` deletes. See [docs/aliases.md](docs/aliases.md) |
| `blacklist` | Per-topic text deny-list with whitelist exceptions: `/blacklist_add`, `/blacklist_del`, `/whitelist_add`, `/whitelist_del`, `/blacklist_rules` lists both, `/blacklist_check` judges a text. Passive — the bot never scans chat. See [docs/blacklist.md](docs/blacklist.md) |
| `blacklist` | Per-topic text deny-list with whitelist exceptions: `/blacklist_add`, `/blacklist_del`, `/whitelist_add`, `/whitelist_del`, `/blacklist_rules` lists both, `/blacklist_check` judges a text, `/blacklist` does either, `/whitelist_rnd` picks a random exception. Passive — the bot never scans chat. See [docs/blacklist.md](docs/blacklist.md) |
| `monkeyd` | `/monkeyd_crawl <url> [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags <url>` list its tags as hashtags |
Disable modules with the `MODULES` environment variable.
Commands marked (admin) require a user ID in `ADMIN_IDS` or the owner; commands
marked (owner) require `OWNER_ID`. Both kinds are hidden from `/help` and the
Telegram command menu. Every other command is public.
Choose modules with the `MODULES` environment variable, a comma-separated list;
empty loads every module.
## Command discovery
@@ -42,6 +47,8 @@ Future commands must follow the
[command parameter conventions](docs/command-parameter-conventions.md). Keep
command metadata, handler usage text, tests, and documentation aligned.
## Feature notes
### Lunar calendar accuracy
`/amlich` and `/duonglich` port Hồ Ngọc Đức's truncated-Meeus algorithm, the
@@ -220,17 +227,26 @@ all.
cmd/server/ entrypoint (long polling + in-process cron + HTTP health)
internal/server/ HTTP route (/ health only; cron has no HTTP route)
internal/telegram/ Telegram long-polling bot wrapper
internal/cron/ in-process cron scheduler
internal/cron/ in-process cron scheduler (UTC)
internal/deploynotify/ startup DM to the owner with the deployed commit
internal/modules/ Module framework, registry, dispatchers, modules
internal/storage/ typed DocStore[T] (Provider + Typed); mongodb runtime + memory (tests). Values persist as flattened native BSON root documents
internal/systemstate/ shared `system` collection helper for startup migration records
internal/log/, metrics/ JSON logging (LOG_LEVEL) and periodic metrics flush
third_party/monkeyd-crawler/ git submodule; resolved by a go.mod replace directive
compose.yml Coolify self-host stack (single bot service)
docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide
docs/command-parameter-conventions.md Command parameter syntax rules
docs/amlich-known-issues.md Lunar algorithm decision and known edge cases
```
## Documentation
- [Self-host deploy and operations](docs/deploy-coolify-selfhosted.md), including the full environment variable list
- [Command parameter conventions](docs/command-parameter-conventions.md)
- [Aliases](docs/aliases.md)
- [Blacklist](docs/blacklist.md)
- [Sticker pack](docs/sticker-packs.md)
- [Lunar calendar algorithm and known issues](docs/amlich-known-issues.md)
- [Agent and contributor rules](AGENTS.md)
## Run locally
Clone with submodules — the `monkeyd` module builds against
@@ -270,8 +286,9 @@ dev bot is created manually; its token is injected through the environment.
The `lol` module reads its schedule from the PandaScore REST API and needs
`LOL_PANDASCORE_TOKEN` (free tier). Without it every `/lol*` fetch fails while
the rest of the bot runs normally. See [`.env.example`](.env.example) for the
full variable list.
the rest of the bot runs normally. See the
[environment table](docs/deploy-coolify-selfhosted.md#environment) for every
variable, and [`.env.example`](.env.example) for a template.
Persistent MongoDB locally (auto-selected when `MONGO_URL` is set):
@@ -295,18 +312,21 @@ use `go test -v ./...` to see individual skip reasons.
```sh
go vet ./...
golangci-lint run
go test -count=1 ./...
go build ./...
```
CI additionally runs the test suite with Go's race detector.
CI runs the same steps, with the test suite under Go's race detector
(`go test -race`), plus an informational `govulncheck` and a `docker build`.
Lint settings live in [`.golangci.yml`](.golangci.yml).
## Deploy
[`docs/deploy-coolify-selfhosted.md`](docs/deploy-coolify-selfhosted.md) covers
Coolify + MongoDB Atlas (free M0), long polling (no public ingress), and
in-process cron. Storage auto-selects `mongodb` when `MONGO_URL` is set; the
cron scheduler runs by default.
in-process cron. Coolify redeploys on every push to `main`. Storage
auto-selects `mongodb` when `MONGO_URL` is set; the cron scheduler always runs.
## License
+2 -2
View File
@@ -33,7 +33,7 @@ services:
# Coolify network for the container health monitor against GET / only.
expose:
- "8080"
# No compose healthcheck: distroless has no shell/curl and cmd/server has no
# -healthcheck flag. Configure Coolify's HTTP monitor against GET / instead
# No compose healthcheck: Coolify's own HTTP monitor covers it, so none is
# defined here. Configure that monitor against GET / instead
# (returns text/plain "miti99bot ok"). Note: a plain / check does not verify
# Mongo connectivity — see docs/deploy-coolify-selfhosted.md.
+3 -9
View File
@@ -149,15 +149,9 @@ notes cannot, and Telegram's send methods for them have no caption field at all.
## Listing
`/aliases` prints the count and every name, sorted, in one message.
**Names only, not what each holds.** The store answers "which keys exist" in a
single call, while naming each kind would cost one read per alias — a round trip
each against MongoDB, on a dispatcher that serves one update at a time. To find
out what a name holds, `/insert` it.
One line per alias, showing what the name holds, with the invocation in a
`<code>` span so tapping it copies a command ready to send:
`/aliases` prints the count and every name, sorted, in one message — one line
per alias, showing what the name holds, with the invocation in a `<code>` span
so tapping it copies a command ready to send:
```
3 aliases:
+2 -2
View File
@@ -15,7 +15,7 @@ remain responsible for parsing and validation.
|---|---|---|
| Required value | `<name>` | `<ticker>` |
| Required comma-separated values | `<name,...>` | `<option,...>` |
| Required remaining text | `<name...>` | `<title...>` |
| Required remaining text | `<name...>` | `<text...>` |
| Optional value | `[name]` | `[date]` |
| Optional remaining text | `[name...]` | `[target...]` |
| Alternatives in an optional group | `[literal | literal <name>]` | `[users | user <username>]` |
@@ -41,7 +41,7 @@ language or extra punctuation without a user-facing need.
```text
/stock_buy <quantity> <ticker>
/renamepack <title...>
/blacklist_del <text...>
/lol [date]
/trongtruonghop [target...]
/stats [users | user <username> | cmd <command_name>]
+69 -56
View File
@@ -15,31 +15,38 @@ Run `miti99bot` as a long-lived container on [Coolify](https://coolify.io) with
- **Storage** — `mongodb` auto-selected when `MONGO_URL` is set (no `KV_PROVIDER`).
- **Cron** — an in-process scheduler (`internal/cron`) runs unconditionally and
fires each module cron on its `Schedule` (UTC).
fires each module cron on its `Schedule`, evaluated in UTC. The only cron
today is the `lol` daily digest at `0 1 * * *` (08:00 ICT).
- **Transport** — long polling (`b.Start`) is the **only** transport. The bot
opens an outbound connection to Telegram and pulls updates, so there is no
public domain, no `/webhook`, and no webhook secret. The container clears any
leftover webhook on startup (`deleteWebhook`) before polling.
## Required environment
## Environment
Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
| Var | Required | Notes |
|---|---|---|
| `TELEGRAM_BOT_TOKEN` | ✅ | from @BotFather |
| `TELEGRAM_BOT_TOKEN` | ✅ | from @BotFather; startup fails without it |
| `MONGO_URL` | ✅ | Atlas SRV string **incl. credentials** — secret, never logged |
| `MONGO_DATABASE` | ✅ | e.g. `miti99bot` |
| `MODULES` | optional | CSV; empty = all modules |
| `OWNER_ID` | optional | owner-only commands (renamed from `BOT_OWNER_ID`) |
| `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) |
| `MODULES` | optional | CSV; empty = all modules, including any added later |
| `OWNER_ID` | optional | Telegram user id for owner-only commands, the deploy DM, and the `/addsticker` pack owner. Unset = owner-only commands are denied and `/addsticker` refuses |
| `ADMIN_IDS` | optional | CSV of Telegram user ids for admin-only commands |
| `STICKER_PACK_NAME` | optional | set `/addsticker` writes to; default `miti99_by_miti99bot`. See [sticker packs](sticker-packs.md) |
| `LOL_PANDASCORE_TOKEN` | ✅ for lol module | PandaScore API token (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) |
| `WHEELOFNAMES_API_URL` | optional | full `/api/gif` endpoint for remote `/wheelofnames` GIF rendering |
| `WHEELOFNAMES_API_TOKEN` | optional | bearer token matching the wheelofnames service `API_TOKEN` |
| `LOL_PANDASCORE_TOKEN` | ✅ for lol module | PandaScore API token (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) |
| `LOG_LEVEL` | optional | `debug`, `info` (default), `warn`, or `error`; logs are JSON on stdout |
| `GOLD_VNAPP_API_KEY` | leave unset | VNAppMob key; unset = the gold module fetches one and caches it in MongoDB |
| `KV_PROVIDER` | leave unset | `memory` or `mongodb`; unset = `mongodb` when `MONGO_URL` is set, otherwise `memory` |
| `PORT` | leave unset | health server port; default `8080` |
| `SOURCE_COMMIT` | never set | provided by Coolify at runtime for the deploy DM (see step 6 below) |
**Leave UNSET on self-host:** `KV_PROVIDER`, `PORT`,
`TELEGRAM_WEBHOOK_SECRET`, and `GOLD_VNAPP_API_KEY`. Stock, coin, and gold URL
overrides are not supported in runtime env; modules use coded defaults.
Stock, coin, and gold provider URL overrides are not supported in runtime env;
modules use coded defaults. There is no `TELEGRAM_WEBHOOK_SECRET`: long polling
has no webhook.
> Cron runs in-process (`internal/cron`) — there is no `/cron` HTTP route and no
> `CRON_SHARED_SECRET`. The scheduler is the sole trigger; nothing inbound.
@@ -86,7 +93,7 @@ reply is immediate.
Atlas admin or cluster-wide. Use a **strong unique password**.
3. **Network access:** add `0.0.0.0/0`.
> **Accepted trade-off (validated decision).** The Coolify host has no stable
> **Accepted trade-off.** The Coolify host has no stable
> egress IP, so the Atlas IP allow-list is open to the internet. This widens
> the database surface. The mandatory compensating controls are: (1) strong
> unique password, (2) least-privilege `readWrite`-on-one-db user, (3) the
@@ -96,31 +103,33 @@ reply is immediate.
4. Copy the `mongodb+srv://…` connection string into `MONGO_URL` and put the
db name in `MONGO_DATABASE`.
> Storage layout: one collection per module. Each document is a flattened native
> document — `{ _id: <user key>, ...payload fields, version, updatedAt }` with no
> `value` envelope. Payload fields are hoisted to the document root so they
> expand and are queryable in Compass. The two non-object values are wrapped in a
> named field: `lol` schedule subscribers under `subscribers` (array) and the
> daily push date under `date`. Concurrency uses the `version` field (optimistic lock);
> `updatedAt` is a BSON Date.
>
> The `stats` collection uses queryable aggregate documents for command/user
> counts and creates indexes on startup. Deleted legacy command rows are
> retained with `deleted: true`; `/stats` queries filter those rows from visible
> results. Stats startup uses the idempotent
> `migration:stats-delete-stock-dividend-v1` migration to retire historical
> `/stock_dividend` rows without erasing them. A historical `system` collection may remain in MongoDB with completed
> migration records; keep those records as audit history. Stock stores cash as
> `vnd`, embeds positions as `assets.<symbol>.{quantity,base,openedAt}`, and
> retains normalized per-user SSI history under
> `dividends.<symbol>.<ssi_event_id>`. Unprocessed retained dividend events are
> replayed on every `/stock_portfolio` until they are processed or expire after
> 90 days; events with no Record date stay informational while SSI is
> rechecked, and later SSI responses that omit an event do not delete the
> retained record. Coin stores cash as `usd` and embeds positions as
> `assets.<symbol>.{quantity,base}`. Stock startup maintenance runs the
> idempotent `migration:stock-dividend-history-v1` migration to remove the
> retired dividend cursor and hashed applied-event ledger.
### Storage layout
- **One collection per module.** Each document is a flattened native document
— `{ _id: <user key>, ...payload fields, version, updatedAt }` with no `value`
envelope. Payload fields are hoisted to the document root so they expand and
are queryable in Compass. The two non-object values are wrapped in a named
field: `lol` schedule subscribers under `subscribers` (array) and the daily
push date under `date`. Concurrency uses the `version` field (optimistic
lock); `updatedAt` is a BSON Date.
- **`stats`** uses queryable aggregate documents for command/user counts and
creates its indexes on startup. Deleted legacy command rows are retained with
`deleted: true`, and `/stats` filters them from visible results.
- **`stock`** stores cash as `vnd`, embeds positions as
`assets.<symbol>.{quantity,base,openedAt}`, and retains normalized per-user
SSI dividend history under `dividends.<symbol>.<ssi_event_id>`. The
[README](../README.md#stock-dividend-commands) describes how those records
are replayed and expired.
- **`coin`** stores cash as `usd` and embeds positions as
`assets.<symbol>.{quantity,base}`.
- **`system`** holds one marker per completed one-time startup migration. Keep
those records as audit history. The current markers are
`migration:stats-delete-stock-dividend-v1` (retires historical
`/stock_dividend` stats rows without erasing them),
`migration:stock-dividend-history-v1` (removes the retired dividend cursor
and hashed applied-event ledger), and
`migration:sticker-drop-legacy-packs-v1` (removes records left by the retired
per-user sticker pack commands).
## 2. Coolify
@@ -132,9 +141,8 @@ reply is immediate.
`replace` directive. Coolify must clone submodules, or the Docker build
fails at `go mod download` with an unresolved
`github.com/tiennm99/monkeyd-crawler`. Turn on Coolify's recursive-clone /
submodule option for the resource. If submodules cannot be enabled, drop the
module instead by setting `MODULES` to the list without `monkeyd` — the build
still needs the submodule, so this is only a runtime opt-out.
submodule option for the resource. There is no build without it: leaving
`monkeyd` out of `MODULES` only disables the commands at runtime.
3. Set the env vars above in Coolify.
4. **No public domain / port** is needed — polling is outbound-only. Do not
publish a port or attach a domain. `expose: 8080` keeps the health endpoint
@@ -152,21 +160,19 @@ reply is immediate.
`compose.yml`; an interpolated empty value can override Coolify's runtime
env-file value.
7. **Health check:** use Coolify's HTTP monitor against `GET /` (returns
`text/plain` `miti99bot ok`). Do **not** use a compose `healthcheck` — the
distroless image has no shell/curl and `cmd/server` has no `-healthcheck`
flag. Note: `/` reports healthy even if Mongo is unreachable (the driver
auto-reconnects on the next op); a DB outage will not auto-restart the
container — accepted trade-off.
`text/plain` `miti99bot ok`). The committed `compose.yml` defines no
`healthcheck`, and `cmd/server` has no `-healthcheck` flag. Note: `/`
reports healthy even if Mongo is unreachable (the driver auto-reconnects on
the next op); a DB outage will not auto-restart the container — accepted
trade-off.
## 3. Command menu
The bot registers its Telegram command menu from loaded public modules on
every startup. The Go module registry is the single source of truth; no separate
command-menu file or manual registration step is required. A command's
description plus optional `Parameters` metadata feed both surfaces. Telegram
renders the command name separately and accepts only a single-line plain-text
description. Both the native menu and `/help` show syntax plus the summary and
omit example invocations.
The bot registers its Telegram command menu from the loaded modules' public
commands on every startup. The Go module registry is the single source of
truth, so no separate command-menu file or manual registration step is
required. See [Command discovery](../README.md#command-discovery) for how the
menu text is built.
## Operations
@@ -210,8 +216,15 @@ cp .env.example .env # fill TELEGRAM_BOT_TOKEN, MONGO_URL, MONGO_DATABASE
docker compose up --build
```
Boot logs should show `storage backend backend=mongodb database=…` (no
connection string), `cron scheduler started`, and `telegram long polling
started`. A request to `http://localhost:8080/` returns `miti99bot ok` (use
`Invoke-WebRequest` in PowerShell or `curl` in a POSIX shell). The bot's webhook
must be unset (the container clears it on startup) or `getUpdates` 409s.
Boot logs are JSON lines. Look for `"msg":"storage backend"` with
`"backend":"mongodb"` and the database name (never the connection string),
`"msg":"cron scheduler started"`, and `"msg":"telegram long polling started"`.
`compose.yml` does not publish port 8080 to the host, so check the health
endpoint from inside the container:
```sh
docker compose exec bot wget -qO- http://127.0.0.1:8080/
```
It returns `miti99bot ok`. The bot's webhook must be unset (the container
clears it on startup) or `getUpdates` 409s.
+4 -4
View File
@@ -15,10 +15,10 @@ written afterwards.
| Command | Parameters | Reply to | What it does |
|---|---|---|---|
| `/addsticker` | `[emoji...]` | sticker, photo, or image document | Adds it to the shared pack and replies with the link |
| `/addsticker` | `[emoji...]` | sticker, photo, image document, video, GIF, or video note | Adds it to the shared pack and replies with the link |
Single-shot: one message, optionally replying to a sticker or image. No
conversation state.
Single-shot: one message replying to the media to add. No conversation state.
[What it accepts](#what-it-accepts) lists every supported kind.
## Configuration
@@ -34,7 +34,7 @@ the configured pack belongs to someone else.
The caller's identity is used nowhere. That is what makes the command stateless:
no records, no keys, no per-user locks, and no ownership checks. It is also why
`/addsticker` needs no storage and fits in `util`.
`/addsticker` needs no per-user storage.
`STICKER_PACK_NAME` **must end in `_by_<this bot's username>`** — Telegram
requires that suffix on every set a bot creates, and refuses to let a bot edit