Shields.io-compatible MAU badges for Telegram bots, with daily history tracking and a small web UI. Live at tgbotmau.quoi.dev.
For example, the live badge of @BotFather:
- Renders
MAU <count>badges in every shields.io style (flat,flat-square,plastic,for-the-badge,social) with the Telegram logo and brand color. Output is byte-identical to shields.io (verified by tests against real shields.io responses). - Discovers MAU by scraping the public
t.me/<username>page: theStart Botbutton proves the username is a bot, theN monthly usersline provides the count. - Falls back to MTProto (
bot_active_usersof the User object) for bots whose MAU is hidden on the web, using a pool of bot tokens:- first contact per account uses
contacts.resolveUsername(heavily rate-limited by Telegram, ~150/day/account — budgeted and persisted); - the result (
user_id+ per-accountaccess_hash) is stored, so all later reads use the cheapusers.getFullUser; FLOOD_WAITpenalties are tracked per account and per method (independent limits) and survive restarts;- user-facing requests queue through a hard per-account limiter (1 req/5 s by default), background refreshes are additionally slowed to 1 req/60 s per account.
- first contact per account uses
- Remembers every requested username (including "not a bot" / "not found")
for
CACHE_TTL(12 h), and re-scrapes all known bots on that cadence, recording one MAU history point per bot per UTC day. Bots that hide their MAU are retried slowly (UPDATER_NO_MAU_BATCHoldest per window). All updater state lives in PostgreSQL, so restarts resume seamlessly. - Exports Prometheus metrics: badge render time, t.me scrape time/errors, MTProto request time/errors (resolve vs getFullUser separately), and per-account connection state (1=UP / 0=DOWN).
| Endpoint | Description |
|---|---|
GET /api/bot/{username}/mau/badge?style=flat |
SVG badge (Cache-Control: max-age=3600, configurable). Unknown MAU renders ???. |
GET /api/bot/{username}/mau |
JSON: {"username","status","mau":null|N,"mau_source","checked_at"} |
GET /api/bot/{username}/mau/history?range=1m|3m|6m|1y|all |
JSON daily history points |
GET /api/openapi.yaml |
OpenAPI 3 spec, rendered with the deployment's real cache settings |
GET /api-docs |
Swagger UI for the spec (self-hosted, no CDN) |
GET /metrics |
Prometheus metrics |
GET /healthz |
Liveness probe |
GET /readyz |
Readiness probe (checks the database) |
GET / |
Web UI (React, embedded into the binary) |
Embedding a badge for your bot (@BotFather here as an example):
[](https://tgbotmau.quoi.dev/?bot=BotFather)<a href="https://tgbotmau.quoi.dev/?bot=BotFather" target="_blank"><img alt="@BotFather MAU" title="@BotFather MAU" src="https://tgbotmau.quoi.dev/api/bot/BotFather/mau/badge?style=flat"></a>Fair use: please don't use the API for mass username enumeration or bulk discovery. Every previously unseen bot may cost a strictly limited Telegram MTProto request (ResolveUsername has a small daily quota), so bulk scanning drains a shared budget and degrades the service for everyone. The API is meant for looking up the MAU of bots you already know — typically your own.
cmd/tgbotmau wiring: config -> store -> scrapers -> service -> http
internal/badge shields.io-compatible SVG renderer (byte-identical port
of badge-maker, anafanafo font metrics included)
internal/tme t.me page scraper (rate-limited, fixture-tested)
internal/mtproto bot-account pool over gotd/td: quota ledger, per-method
flood waits, persisted peers, auto-reconnect
internal/service domain logic: cache -> t.me -> MTProto, singleflight
deduplication, history recording, auto-updater
internal/store PostgreSQL 18 (uuidv7 PKs), embedded goose migrations
internal/httpapi handlers, metrics middleware, embedded SPA serving
internal/metrics every exported Prometheus metric in one place
web Astro SSG frontend (React islands, DaisyUI, Recharts,
Swagger UI): the page shell is pre-rendered at build
time, hydrated on load, embedded via go:embed
Concurrency notes: any number of concurrent requests for one username
(users + auto-updater alike) collapse into a single upstream scrape via
singleflight; the scrape runs on a detached context so a cancelled
requester never fails the other waiters, while every waiter is released as
soon as its own context ends. A failed MTProto lookup never overwrites a
previously known MAU and never fabricates history points. All rate limits
and quotas are enforced per account with persisted state, so restarts never
overshoot Telegram's limits.
Run exactly one instance per database: the MTProto resolve quota and flood waits are mirrored in process memory, so multiple replicas would overspend Telegram's limits.
docker compose up --build # app on :8080, PostgreSQL 18 includedOr run the published image against your own PostgreSQL 18+:
docker run -d --name tgbotmau -p 8080:8080 \
-e DATABASE_URL=postgres://user:password@your-postgres:5432/tgbotmau \
ghcr.io/kivapple/tgbotmau:latestOr manually: build the frontend, then the binary (the frontend is embedded):
cd web && pnpm install --frozen-lockfile && pnpm run build && cd ..
go build -o tgbotmau ./cmd/tgbotmau
DATABASE_URL=postgres://localhost/tgbotmau ./tgbotmauConfiguration is environment-only; see .env.example for
every knob. Without BOT_TOKENS the service runs in t.me-only mode.
go test ./... # unit tests
TEST_DATABASE_URL=postgres://localhost/tgbotmau_test go test ./internal/store # DB tests
cd web && pnpm dev # astro dev server (proxies /api to :8080)This codebase was written with extensive use of AI coding tools, working under the close architectural direction, review and quality control of an experienced software engineer.
Licensed under the MIT License.