Telegram anti-spam bot that greets newcomers with an animated math captcha readable only in motion. A single frame, or a per-pixel average of all frames, is statistically flat noise: OCR, screenshots and multimodal models see nothing, while humans read the digits effortlessly through motion perception.
Русская версия: README.RU.md
The expression N+M is rendered as two layers of noise moving relative to each other. The glyph region and the background share the same texture statistics, so the digits exist only as coherent motion, never as pixels:
- Still frame: flat noise, nothing to OCR.
- Frame averaging (pixel delay map): flat noise again.
- Human eye: reads the digits in a fraction of a second.
On top of the base mechanics there are optional shields against automated video analysis:
| Shield | What it does | What it defeats |
|---|---|---|
| Motion preset 2 | Both layers move in the same direction with different, sine-wobbling speeds | Shift-matching: contrasting +shift vs -shift match maps yields nothing, and no constant inter-frame offset exists |
| Sprinkle | Re-seeds a share of pixels with fresh noise every frame | Dirties optical-flow and match maps; the eye averages the flicker out |
| Decoy | Bakes a faint static fake expression into the field | Frame averaging reveals the fake instead of the answer, and replying with the fake sum triggers the temporary ban |
- A newcomer must reply with the correct number within the timeout (60 s by default); failure means a temporary ban (5 min by default), after which rejoining is allowed.
- Every message from a pending newcomer except the correct answer is deleted.
- Members seen before, and anyone who already passed, never get a captcha.
PASSED_TTL_DAYSmakes that memory expire; by default it lasts forever. - Allowlisted bots and bots added by an administrator stay; other bots are kicked.
- Updates that waited in Telegram's queue longer than
MAX_UPDATE_AGE_SEC(5 min by default) are ignored, and a newcomer Telegram already reports as banned or gone gets no captcha. Without both, a bot coming back from an outage works through a day of backlog and greets people who joined hours ago. - If captcha rendering fails, the newcomer is let through with a loud log line, so an outage does not lock people out.
CAPTCHA_FAIL_CLOSED=trueflips that: a broken renderer bans instead, and nobody slips past a captcha that was never sent.
The bot works in groups and supergroups. A broadcast channel has no one to challenge — subscribers cannot answer in it — so adding the bot there does nothing.
State is a JSON snapshot on disk: held in memory, flushed atomically every few seconds and on shutdown. Restarts lose nothing that matters.
-
Create a bot with @BotFather and copy the token.
-
Add the bot to your group as an administrator with "delete messages" and "ban users" permissions.
-
Create
.envnext todocker-compose.yml:BOT_TOKEN=123456:ABC...
-
Run:
docker compose up -d --build
Everything is configured through environment variables. No database, no admin panel.
| Variable | Default | Description |
|---|---|---|
BOT_TOKEN |
required | Telegram bot token |
CAPTCHA_TIMEOUT_SEC |
60 |
Seconds a newcomer has to answer |
CAPTCHA_STYLE |
l |
Noise look: l for 2px bands, g for 1px bands, dots for sparse subpixel dots |
CAPTCHA_MOTION |
1 |
1 moves the layers in opposite directions, 2 moves them in the same direction with wobbling speeds (anti shift-matching) |
CAPTCHA_SPRINKLE |
0 |
Share of pixels re-seeded with fresh noise each frame, 0..1 (e.g. 0.01); raises analysis cost, costs readability and file size |
CAPTCHA_DECOY |
false |
Bake in a faint fake expression; answering it triggers the temporary ban |
CAPTCHA_MAX_ATTEMPTS |
3 |
Wrong numeric answers allowed before the ban (chatter is only deleted, not counted) |
CAPTCHA_BAN_SEC |
300 |
Seconds a newcomer remains banned after failing the captcha (60..31536000) |
CAPTCHA_FAIL_CLOSED |
false |
Ban the newcomer for CAPTCHA_BAN_SEC when the captcha cannot be rendered, instead of letting them in |
PASSED_TTL_DAYS |
0 |
Days a passed user is remembered; 0 keeps them forever |
MAX_UPDATE_AGE_SEC |
300 |
Seconds an update may have waited in Telegram's queue before it is ignored; 0 processes the whole backlog after an outage |
CAPTCHA_OPERAND_DIGITS |
1 |
Digits per operand: 1 keeps the glyphs large and the sum instant, 2 widens the answer space from 19 to about 180 at the price of readability |
ALLOWED_BOT_IDS |
unset | Comma-separated numeric bot IDs allowed regardless of who added them; other bots must be added by an administrator |
ALLOWED_CHAT_IDS |
unset | Comma-separated numeric chat IDs served by the bot; unset or empty allows every chat |
DATA_FILE |
data/state.json |
State snapshot location |
MESSAGES_FILE |
data/messages.json |
Optional message templates, see below |
FFMPEG_PATH |
ffmpeg |
ffmpeg binary (bundled in the Docker image) |
TELEGRAM_TEST_MODE |
false |
Talk to Telegram's test environment |
TELEGRAM_API_ROOT |
Telegram | Bot API root for compatible servers such as Telegym |
CAPTCHA_TEST_SEED |
unset | Deterministic PRNG seed for integration tests; requires TELEGRAM_API_ROOT |
Drop a messages.json into the data directory to override the chat texts (any language you like). Placeholders: %username% becomes a clickable mention of the newcomer, %timer% the allowed seconds. Templates are Telegram HTML.
{
"captcha": "%username%, prove you are human: reply with the number within %timer% seconds or say goodbye.",
"welcome": "%username%, one of us. Welcome aboard."
}npm install
npm test # unit tests, ffmpeg not required
npm run typecheck
BOT_TOKEN=... npm run devRendering the animation locally requires ffmpeg on PATH (or FFMPEG_PATH).
The dot-field mechanics are inspired by Ghost Font, a motion-defined AI vision benchmark.