Minimal grammY Telegram anti-spam bot powered by TypeSafe Jev
  • TypeScript 99.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Claude 8023cd58ff
Some checks failed
CI / deploy-image (push) Failing after 23s
CI / check (push) Failing after 3m46s
CI: run on git.borodutch.com (Forgejo Actions)
Non-Pages workflows move to .forgejo/workflows; GitHub (a push mirror) keeps only Pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:43:40 -07:00
.forgejo/workflows CI: run on git.borodutch.com (Forgejo Actions) 2026-10-22 15:43:40 -07:00
.github CI: run on git.borodutch.com (Forgejo Actions) 2026-10-22 15:43:40 -07:00
docs Delete free-copy book bait Laya keeps under the gate (#153) (#52) 2026-10-21 10:20:01 -07:00
migrations Escalate repeat sends across groups and delete earlier copies (#124) (#43) 2026-10-14 14:14:35 -07:00
scripts Report confirmed deletions privately and bind approved Laya policy (#34) 2026-10-10 15:48:23 -07:00
src Delete free-copy book bait Laya keeps under the gate (#153) (#52) 2026-10-21 10:20:01 -07:00
.env.example Shadow-classify with a self-hosted model; opt-in Jev training capture (#33) 2026-10-06 18:12:58 +00:00
.gitignore Build Jev-powered Telegram anti-spam bot 2026-09-18 16:56:06 -07:00
bun.lock Enrich Jev moderation with bounded public Telegram previews (GEN-KANEO-40) (#27) 2026-09-28 23:19:42 +00:00
Dockerfile Upgrade Bun to 1.4.4 (Oct 2026 security fixes) (#53) 2026-10-22 10:22:39 -07:00
LICENSE Build Jev-powered Telegram anti-spam bot 2026-09-18 16:56:06 -07:00
package.json Enrich Jev moderation with bounded public Telegram previews (GEN-KANEO-40) (#27) 2026-09-28 23:19:42 +00:00
README.md Lower the promo-pattern floor from 0.30 to 0.20 (#152) (#51) 2026-10-21 07:40:29 -07:00
tsconfig.json Build Jev-powered Telegram anti-spam bot 2026-09-18 16:56:06 -07:00

Jev Anti-Spam Bot

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.

Setup

  1. Open @BotFather, run /setprivacy, select the bot, and choose Disable. This is a one-time bot setting required to receive every group message.

  2. Set the environment variables:

    cp .env.example .env
    
    • TELEGRAM_BOT_TOKEN: token from BotFather
    • TYPESAFE_API_KEY: TypeSafe API key
    • DATABASE_URL (optional): private PostgreSQL connection URL for chat/deletion statistics
  3. Install and run:

    bun install
    bun run check
    bun start
    
  4. 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.

Policy

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.

An account that sends the same content again, in the same group or in another group the bot moderates, raises its effective score. Each other copy within 24 hours halves the distance to 1, for copies with a model score of at least 0.5 and at least 30 normalized characters. Once the effective score crosses the gate, the bot also deletes that account's earlier copies in every group. Copies are matched through a content-free actor+content hash in PostgreSQL, so they survive restarts; see Repeat-send escalation.

Three ad shapes that Laya keeps under the gate are also deleted by structural rules once the model scores them at least 0.20: side-gig recruitment (a work offer, a pay rate per period and a move-to-DM call), first-person "I took the test in @…bot" testimonials, and P2P crypto trade asks (a buy, sell or exchange intent for a crypto asset plus a DM or deal cue, such as "есть кто продаёт усдт? Напишите, договоримся"). Answers to another member's request, warnings, quoted reports and attributed reposts never match; see Ad-shape rules.

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 DATABASE_URL is set, 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 PostgreSQL; 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.

Validation

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_JEV=1 plus a valid TYPESAFE_API_KEY:

RUN_LIVE_JEV=1 bun test src/spam.live.test.ts

The 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_JEV=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_JEV=1 bun test src/media.live.test.ts; the previously accused media cases are ambiguity controls, not confirmed recall misses. PostgreSQL integration tests separately require a disposable test database (TEST_DATABASE_URL); never point them at production.

Shadow classifier and self-hosted models

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.81

It 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 (opt-in)

TRAINING_CAPTURE=true (requires DATABASE_URL) stores every successful Jev classification in the training_samples table so a self-hosted model can be distilled from Jev: the exact request sent to Jev (current message text, recent same-sender messages, reply previews, and the sender's bio/personal-channel text when it was used), Jev'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 a self-hosted server, set JEV_URL to its /v1/systemone URL, TYPESAFE_API_KEY to its bearer key and JEV_MODEL to the model name it reports (the spam cache only seeds verdicts from the configured model), then retune SPAM_THRESHOLD from the comparison.

Docker

docker build -t jev-antispam-bot .
docker run --rm --env-file .env jev-antispam-bot

Production (Easypanel) builds this Dockerfile. Its first stage runs bun run check (typecheck plus the tests that need no database) on the same Bun that runs the bot, and bun install --frozen-lockfile fails the build if bun.lock is out of date. The Bun version in the Dockerfile must match BUN_VERSION in .github/workflows/ci.yml; the CI deploy-image job enforces this and builds the image.

Optional PostgreSQL statistics

Create a PostgreSQL database and set DATABASE_URL to its connection URL:

DATABASE_URL=postgresql://jev_antispam:<password>@localhost:5432/jev_antispam

The database user needs permission to connect, create tables in its schema, and read/write those tables. Keep the database private and use persistent storage in production.

The bot applies migrations/001_chat_stats.sql automatically on the first queued batch. 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;

To disable statistics, remove DATABASE_URL and restart the bot. Existing database data is left untouched.

License

MIT