mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-31 16:21:47 +00:00
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.
211 lines
10 KiB
Markdown
211 lines
10 KiB
Markdown
# Deploy: Self-host (Coolify + MongoDB Atlas)
|
|
|
|
Run `miti99bot` as a long-lived container on [Coolify](https://coolify.io) with
|
|
[MongoDB Atlas](https://www.mongodb.com/atlas) (free M0) for storage.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Telegram <── long poll (getUpdates) ── container (outbound only)
|
|
in-process scheduler ───────────────────> module crons
|
|
MongoDB Atlas (db / one collection per module + system metadata)
|
|
Coolify env vars (plain secrets)
|
|
NO public ingress (polling = outbound only; no domain, no /webhook, no TLS in)
|
|
```
|
|
|
|
- **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).
|
|
- **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
|
|
|
|
Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
|
|
|
|
| Var | Required | Notes |
|
|
|---|---|---|
|
|
| `TELEGRAM_BOT_TOKEN` | ✅ | from @BotFather |
|
|
| `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`) |
|
|
| `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` |
|
|
|
|
**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.
|
|
|
|
> 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.
|
|
|
|
### Optional wheelofnames renderer
|
|
|
|
`/wheelofnames` uses a remote GIF renderer when `WHEELOFNAMES_API_URL` is set.
|
|
The standard renderer is a deployment of
|
|
[`tiennm99/wheelofnames`](https://github.com/tiennm99/wheelofnames), but any
|
|
service that implements the same `/api/gif` contract can be used. Set the URL
|
|
to the full GIF endpoint and set the token to the same value as the service
|
|
`API_TOKEN`:
|
|
|
|
```env
|
|
WHEELOFNAMES_API_URL=http://wheelofnames:3000/api/gif
|
|
WHEELOFNAMES_API_TOKEN=<same value as wheelofnames API_TOKEN>
|
|
```
|
|
|
|
Use a public HTTPS URL instead when the bot cannot reach the service on a
|
|
private Coolify/Docker network:
|
|
|
|
```env
|
|
WHEELOFNAMES_API_URL=https://wheelofnames.example.com/api/gif
|
|
WHEELOFNAMES_API_TOKEN=<same value as wheelofnames API_TOKEN>
|
|
```
|
|
|
|
The bot sends outbound HTTP only; no public bot ingress is required. Remote
|
|
renders use `512px`, `20fps`, and `7` seconds total by default. If the remote
|
|
service is unset, unavailable, unauthorized, or returns a non-GIF response,
|
|
`/wheelofnames` falls back to the same plain text winner reply as `/random`.
|
|
Successful GIF replies include the result behind Telegram spoiler formatting.
|
|
|
|
## 1. MongoDB Atlas (M0)
|
|
|
|
1. Create a free **M0** cluster (512 MB — ample for the tiny paper-trading KV).
|
|
2. **Database user (least privilege):** create a user with role
|
|
**`readWrite` on the single app database only** (e.g. `miti99bot`) — never
|
|
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
|
|
> 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
|
|
> connection string is a secret and is never logged (the bot logs only the
|
|
> database name on startup).
|
|
|
|
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.
|
|
|
|
## 2. Coolify
|
|
|
|
1. New resource → from this Git repo (Docker Compose), or a prebuilt image.
|
|
The committed [`compose.yml`](../compose.yml) defines the single
|
|
`bot` service.
|
|
2. **Enable submodule checkout.** The `monkeyd` module builds against
|
|
`third_party/monkeyd-crawler`, a git submodule wired in through a `go.mod`
|
|
`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.
|
|
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
|
|
reachable only inside Coolify's network.
|
|
5. **Exactly one replica.** Telegram permits only one `getUpdates` consumer per
|
|
bot token; a second poller gets HTTP 409, and a second in-process scheduler
|
|
double-fires crons. Prefer **stop-first redeploys** so two containers never
|
|
overlap near a cron time.
|
|
6. **deploynotify commit SHA:** `SOURCE_COMMIT` is a Coolify predefined
|
|
variable. The bot reads it at startup and DMs the owner on every boot;
|
|
outside Coolify (local `docker compose up`) it is unset and the DM shows
|
|
`unknown`. Keep "Include Source Commit in Build" disabled: that setting
|
|
affects build args only, is not needed for this runtime path, and would
|
|
invalidate Docker cache on every commit. Do not add `SOURCE_COMMIT` to
|
|
`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.
|
|
|
|
## 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.
|
|
|
|
## Operations
|
|
|
|
The live deployment is the Coolify container and MongoDB is the sole system of
|
|
record. Keep exactly one replica running. To confirm Telegram is in polling mode:
|
|
|
|
```sh
|
|
# POSIX shells (Linux/macOS)
|
|
curl "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getWebhookInfo"
|
|
```
|
|
|
|
```powershell
|
|
# PowerShell
|
|
Invoke-RestMethod "https://api.telegram.org/bot$env:TELEGRAM_BOT_TOKEN/getWebhookInfo"
|
|
```
|
|
|
|
`url` should be empty. If needed, clear the webhook explicitly:
|
|
|
|
```sh
|
|
# POSIX shells (Linux/macOS)
|
|
curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/deleteWebhook" \
|
|
--data "drop_pending_updates=false"
|
|
```
|
|
|
|
```powershell
|
|
# PowerShell
|
|
Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$env:TELEGRAM_BOT_TOKEN/deleteWebhook" -Body @{ drop_pending_updates = "false" }
|
|
```
|
|
|
|
## Local smoke test
|
|
|
|
```powershell
|
|
# PowerShell
|
|
Copy-Item .env.example .env # fill TELEGRAM_BOT_TOKEN, MONGO_URL, MONGO_DATABASE
|
|
docker compose up --build
|
|
```
|
|
|
|
```sh
|
|
# POSIX shells (Linux/macOS)
|
|
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.
|