A minimal grammY Telegram bot that asks TypeSafe's Jev model whether each group message is spam and deletes only high-confidence matches.
It targets common Telegram spam:
- token, investment, referral, gambling, giveaway, and service shilling;
- “check my bio/profile/description” and DM/channel funnels;
- low-substance emoji or generic-engagement hooks backed by adult-content bio/personal-channel metadata;
- phishing, wallet drainers, fake support, and fraudulent airdrops;
- unsolicited bot/game promotion, sexual/DM bait, and easy-money pitches;
- unsolicited testimonial/social-proof promotion of bots, channels, groups, mini apps, sites, courses, or providers, even without a start/join/buy command;
- unsolicited service or product ads that funnel members to DMs, email, or another direct contact;
- repetitive, irrelevant, or mass-mention spam.
Group administrators, bot senders, and genuine anonymous group administrators (sender_chat.id equals the receiving group) are exempt. Channel identities are exempt only when a fresh Telegram getChat lookup proves that they are the receiving group’s official linked_chat_id. External channel identities are analyzed, even when Telegram supplies a synthetic bot sender or no from user. Forwarding alone never establishes an exemption. If Jev or Telegram is unavailable, the bot leaves the message untouched.
-
Open
@BotFather, run/setprivacy, select the bot, and choose Disable. This is a one-time bot setting required to receive every group message. -
Set the environment variables:
cp .env.example .env
TELEGRAM_BOT_TOKEN: token from BotFatherZAI_API_KEY: Z.AI API key (coding plan, https://z.ai) — the GLM vision classifier endpointDUCKDB_PATH(optional): local DuckDB database file for chat/deletion statistics and the deletion audit outbox. Defaults to./data/antispam.duckdb; the directory is created automatically.
-
Install and run:
bun install bun run check bun start
-
Add the bot to a group as an administrator with Delete messages permission.
From then on, adding it as an admin is all a group owner needs to do. Run /status in the group to verify the deletion permission.
Every new or edited text message or media caption from a non-admin group member or an external channel identity is evaluated in one Jev request using twenty-two spam questions plus one linkage question per recent message. For eligible human messages up to 280 characters, the bot makes a best-effort request to Telegram for the numeric sender's private-chat bio and, when exposed, the sender's personal-channel title and description; an uncaptioned sticker, photo, video, or animation from a non-admin human is also eligible so a profile-bait hook cannot bypass evaluation. Enrichment always resolves the actual sender's own profile: an original forward author's profile is never attributed to the forwarding member. Telegram does not expose private-chat metadata for every group member; inaccessible profiles keep uncaptioned media unclassified and fall back to normal message-only classification for text. A failed personal-channel lookup preserves an already retrieved bio, and retrieval outcomes are logged only as privacy-safe source and outcome categories rather than profile text. Successful and empty results expire from memory after ten minutes, failures (including partial channel failures with a preserved bio) after one minute, and settled plus pending lookups share a 1,000-entry bound. The text fields are sent only as structured Jev input and are never logged, persisted, or added to message history. Telegram stories, profile-photo image content, avatar OCR, and personal-channel usernames are not sent to this text moderation path.
The bot keeps up to ten messages per chat and sender in memory for about ten minutes so Jev can recognize a spam pitch split across several messages. After any spam signal reaches SPAM_THRESHOLD (default 0.81), Jev separately identifies the contiguous recent messages that belong to the same campaign and the bot deletes only that linked suffix plus the current message. Uncaptioned media uses only the media-profile signal, never the text signals; conversely the media signal cannot delete text or captions. Its bounded, attributed reply preview is preserved. Missing media/conversation evidence is not proof of solicitation: standalone uncaptioned heart stickers with even a promotional profile remain ambiguous and are kept; relevant requested media from that same profile must also be kept. Only explicit promotional profile plus conversational evidence establishing an unsolicited hook can clear the configured gate. A media verdict deletes only the current media message and never an earlier fragment. The model is pinned to jev-1.13.0 so a new model cannot silently change moderation behavior.
History keys distinguish users from channel identities and isolate receiving groups; synthetic Telegram sender users never combine separate channels. The history is process-local and expires automatically; it is not persisted or logged. The bot does not log message text, sender identity, profile metadata, or personal-channel metadata. Each analyzed message produces structured JSON logs with chat/message IDs, message size and link count, a boolean profile-metadata-presence flag, a media-only flag, all spam-signal probabilities, the strongest signal, the final keep/delete decision, model version, and analysis duration. Failed optional profile lookups for text continue with message-only classification, while uncaptioned media without usable sender-owned profile metadata is kept unclassified; failed classifications emit a terminal fail-open keep result with unavailable probabilities, and successful deletions are logged separately.
When the DuckDB file is writable, the bot also records each chat ID observed in normal Telegram updates, first/last-seen timestamps, chat type, membership-active state when Telegram supplies it, and a lifetime count of confirmed successful deletions. It never stores message text, user profiles, Jev prompts, or credentials unless training capture is explicitly enabled (below). Statistics are optional and fail open: database startup, migration, latency, or outages never block moderation or Telegram deletion calls.
Database writes use one connection and one bounded asynchronous flusher. Chat sightings are coalesced, deletion identities are deduplicated by (chat_id, message_id), and each atomic batch increments counters only for newly inserted identities. Dedupe identities expire after 90 days; lifetime counters do not. The defaults cap pending memory at 1,000 chats plus 1,000 deletions, batch up to 100 of each per flush, retry with exponential backoff and jitter, and attempt a five-second shutdown drain without bypassing retry delays. New observations are ignored once shutdown begins, and no timer or database write starts after the store-close boundary. An abrupt process/container crash, shutdown deadline, or queue overflow can lose records that have not reached the DuckDB file; moderation remains available and overflow/failure health is emitted as rate-limited structured logs.
Every new/edited message skipped before classification emits exactly one message_skipped record with decision:"keep", receiving chat/message IDs, edited flag, and a specific reason: private_chat, unsupported_chat_type, no_text_or_caption, media_profile_unavailable, missing_sender, bot_itself, bot_sender, group_admin, anonymous_group_admin, official_linked_channel, unsupported_sender_chat, sender_chat_metadata_invalid, sender_chat_metadata_failed, or admin_metadata_failed. No sender ID, channel ID, display name, forward origin, text, caption, or API error description is logged. Mandatory sender-chat or administrator metadata failures keep the message and do not add it to classification history; a kept uncaptioned-media message is likewise never added.
bun run check runs typechecking and deterministic tests, including the real grammY handlers with mocked Telegram/classifier calls. These tests prove routing, deletion selection, skip telemetry, history isolation, and unchanged statistics hooks—not live Jev probabilities or Telegram UI behavior.
Live model regression tests require explicit RUN_LIVE_ZAI=1 plus a valid ZAI_API_KEY:
RUN_LIVE_JEV=1 bun test src/spam.live.test.tsThe suite includes a known Chinese advertisement, synthetic testimonial promotions across destination types, and forwarded/non-forwarded controls for requested recommendations, ordinary experience, warnings, support, citations, moderation quotes and bare links. To check a private reported post without retaining it as a fixture, supply its transcription on stdin with JEV_PRIVATE_FIXTURE_STDIN=1 RUN_LIVE_ZAI=1 bun test src/spam.live.test.ts -t "private screenshot"; the test asserts probabilities without printing the text. The authorized default gate is 0.81 (a score cutoff, not calibrated correctness); do not treat mocked handler outcomes as model calibration. Optional real-account Telegram checks provide additional evidence but do not block the General review/test/CI release gate.
Media-profile fixture disposition and attribution boundaries are documented in Media-profile evidence. Run its optional pinned-model suite with RUN_LIVE_ZAI=1 bun test src/media.live.test.ts; the previously accused media cases are ambiguity controls, not confirmed recall misses. DuckDB integration tests use disposable files under /tmp automatically; they never touch production data.
Jev's /v1/systemone wire format is also served by open-weight decision models such as Cloudflare's Clef-flash and Laya (laya-serve). Set SHADOW_URL (plus optional SHADOW_API_KEY, SHADOW_MODEL, SHADOW_TIMEOUT_MS) to send every classified message to a second server in parallel. Its verdict is logged as shadow_analyzed next to the primary decision (delete, keep, cache_delete or failed) and never deletes anything or delays moderation; a slow or unreachable shadow only logs a failed row. The log has the same privacy limits as message_analyzed: IDs, probabilities and timing, never text.
Compare the two from logs:
docker service logs <service> 2>&1 | bun scripts/compare-shadow.ts 0.81It prints agreement, Jev-only and shadow-only spam at several gates, and the disagreeing messages as t.me/c links (kept messages are still in the chat).
TRAINING_CAPTURE=true stores every successful classifier classification in the training_samples table so a self-hosted model can be distilled from the cloud model: the exact request sent to the classifier (current message text, recent same-sender messages, reply previews, and the sender's bio/personal-channel text when it was used), the model's per-question answers, the verdict, and chat/message IDs. Sender IDs are not stored. This does persist message and profile text, so enable it only where that is acceptable. Rows older than TRAINING_CAPTURE_DAYS (default 30) and beyond 200,000 rows are pruned hourly; under load writes are dropped rather than queued, and failures never affect moderation. Remove the variable to stop capturing; DELETE FROM training_samples erases what was kept.
To switch the primary classifier to another GLM model, set ZAI_MODEL to its slug (the spam cache only seeds verdicts from the configured model). The approved policy pin applies only while the configured model equals the approved one; otherwise set your own SPAM_THRESHOLD and retune from shadow comparisons.
docker build -t jev-antispam-bot .
docker run --rm --env-file .env jev-antispam-botEnabled by default (IDENTITY_SCREENING=false to disable). On every join — and again when a member renames — the bot screens the identity:
- Deterministic pre-filter (free, no model): name/@username checked against admin names (unicode-normalized similarity) and impersonation keywords (
support,admin,helpdesk,поддержка, … with word boundaries). - Model evidence: profile bio/personal channel → Jev text router; profile photo → GLM vision ("is this photo itself an ad?"); if impersonation is plausible (keyword or name match), the member's photo is compared with the admin's photo (two images in one GLM request).
- Ban rule: an action only happens when a model confirms (
probability ≥ SPAM_THRESHOLD) and a deterministic signal corroborates it (keyword, ≥0.9 name similarity, or the avatar itself being an ad). A model outage is fail-open — no model, no ban.
Every ban is reported to the audit chat with reasons and scores; failed bans are logged. Renames are detected by comparing names on each message (Telegram has no rename event); members already present before startup are not retroactively screened.
Statistics, the verdict cache and the deletion-audit outbox all live in one embedded DuckDB file (DUCKDB_PATH, default ./data/antispam.duckdb). DuckDB is a single-writer embedded database: run exactly one bot instance per file. The bot applies migrations/duck/*.sql automatically on first use; the migration is idempotent, so no separate migration command is required. After the bot receives a normal update, a stats_storage_ready log confirms that storage is available. You can inspect the totals with:
SELECT COUNT(*) AS known_chats, COALESCE(SUM(successful_deletions), 0) AS successful_deletions
FROM known_chats;(e.g. bun -e 'const {DuckDBInstance}=await import("@duckdb/node-api"); const c=await (await DuckDBInstance.create("./data/antispam.duckdb",{readOnly:true})).connect(); console.log(await (await c.run("SELECT ...")) ...') — or stop the bot and open the file read-only with any DuckDB client.)
Statistics are optional in the failure sense: storage problems never block moderation or Telegram deletion calls. To start fresh, stop the bot and delete the file; nothing else references it.
MIT