Skip to content

Fix: detect rich-message spam bypassing text-based filters - #51

Merged
rezhajulio merged 8 commits into
rezhajulio:mainfrom
TayoBus:fix/rich-message-spam
Oct 17, 2026
Merged

rezhajulio merged 8 commits into
rezhajulio:mainfrom
TayoBus:fix/rich-message-spam

Conversation

@TayoBus

@TayoBus TayoBus commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On 2026-10-17 a spammer forwarded adult spam ("HOT 2026 DROP", t.me/base6d1ef16_bot links) into the group as a Telegram rich message. It sat there for hours until a manual admin delete.

Root cause: rich messages arrive with empty message.text — the whole payload lives in message.rich_message page blocks. The bot's filters only read text/caption:

  • ai_spam_monitor (group 7): _classifiable_text() returned "", so ai_spam_filter never matched — verified in Logfire, the update was dispatched but group 7 never ran and zero classification lines were logged.
  • inline_keyboard_spam (group 1): has_non_whitelisted_inline_keyboard_urls only inspects message.reply_markup; rich pageButton URL buttons live in the block tree, so it completed without flagging.

Changes

  • New bot.services.rich_message: duck-typed walker extracting plain text (headings, paragraphs, table cells, button labels, custom emoji alt text, quotation/media credit lines, list-item labels) and deduplicated URLs (buttons, text links, login/web-app/copy-text payloads, inline RichTextButton nodes, plus URL-shaped strings in plain text — rich blocks carry no entity metadata) from rich message blocks, plus has_rich_buttons(). Depth budget (64): over-budget nesting is fail-closed for the button predicate. aiogram 3.31 already parses Message.rich_message; the data was available, just unread.
  • Group 1 enforcement: rich messages containing buttons are now auto-deleted and the sender restricted. Rationale: no stock Telegram client can compose rich messages or buttons — they are only sendable via Bot API (bots, already exempt) or crafted over MTProto (spammers), or forwarded. Same "not client-craftable" rule as inline keyboard spam. URL-only rich content without buttons goes to the AI monitor, mirroring how plain text links are handled. Linked-channel auto-forwards (is_automatic_forward) are skipped — admin-configured content must never trigger delete+restrict.
  • Probation: has_non_whitelisted_link() now also scans rich block URLs, so probation users cannot smuggle links inside rich text (no behavior change for duplicate_spam's short-message gate).
  • Bio-bait: the bait-phrase matcher now also reads rich text, closing the cheapest pivot (short text-only rich spam, e.g. "cek bio aku").
  • Duplicate spam: extracted rich text is the fallback tracking key, so rich-only floods are counted.
  • AI monitor: _classifiable_text() now includes rich text + URLs (light unicode normalization, classifier input truncated to 8000 chars), so URL-less rich spam still reaches the classifier and admin alerts.
  • Rich content without buttons is never auto-deleted — it goes through the monitor-only alert path.

Verification

  • Independent deep review (5 reviewer agents, reports in reviews/2026-10-17-rich-spam/, gitignored): verdict merge after fixes — 2 blockers (B1/B2) and mediums M1–M5/M7–M9 all addressed in this branch.
  • 56 new tests in tests/test_rich_message.py (+3 in neighboring suites); full suite 1353 passed, ruff check and mypy clean.
  • End-to-end simulation: the incident payload reconstructed as a raw Bot API update dict now trips both ai_spam_filter and has_rich_message_buttons (both were False before the fix).

Rich messages (Message.rich_message) arrive with empty text while their
payload hides in page blocks, so the AI spam monitor's text/caption-only
filter never classified them and the inline-keyboard URL check (which
only inspects reply_markup) missed their URL buttons. This let the
2026-10-17 adult-spam forward (t.me/base6d1ef16_bot) sit in the group
for hours until a manual admin delete.

- New bot.services.rich_message: duck-typed walker extracting plain
  text (headings, paragraphs, table cells, button labels, custom emoji
  alt text) and deduplicated URLs (buttons, text links, login/web-app)
  from rich message blocks.
- Group 1 (inline keyboard spam) now also auto-deletes rich messages
  carrying non-whitelisted URLs, with its own notification wording.
- ai_spam_monitor now includes rich text + URLs in the classified text,
  so URL-less rich spam still reaches the classifier and admin alerts.
- 21 new tests; full suite 1320 passed, ruff + mypy clean.
Per review: no stock Telegram client can compose rich messages or their
buttons, so button presence is the automation signature (same rationale
as inline keyboard spam). URL-only rich content without buttons now goes
to the AI monitor, mirroring how plain text links are handled, instead
of being auto-deleted.

- services.rich_message: new has_rich_buttons() covering block buttons
  and inline RichTextButton nodes.
- anti_spam: has_rich_message_buttons() replaces the URL-based rich
  detector; notification wording updated to mention buttons.
- Tests updated: whitelisted-URL buttons are still deleted; URL-only
  rich content is ignored by group 1.
- Walker now traverses RichBlockCaption.credit and RichTextButton.button,
  so text/URLs hidden in quotation credits and inline text buttons reach
  both the AI monitor and the button detector.
- Group 1 skips is_automatic_forward messages: linked-channel
  auto-forwards are admin-configured and must never trigger
  delete+restrict against the channel/service sender.
- Tests: credit extraction, inline button URL/text extraction,
  button-in-credit detection, auto-forward skip.
has_non_whitelisted_link() now also scans extract_rich_urls(), so
probation users cannot smuggle non-whitelisted links past the filter
inside rich text blocks. No behavior change for duplicate_spam: rich
messages without text return before the link check there.
Blockers:
- B1: feed rich text into bio-bait matching (short text-only rich spam
  was invisible to every layer)
- B2: duplicate detection falls back to extracted rich text as the
  tracking key (rich-only floods were never tracked)

Walker hardening:
- M1: extract RichBlockListItem.label
- M2: descend into copy_text/login_url/web_app/chat-picker payloads;
  extract_rich_urls also detects URL-shaped strings in plain text
  (rich blocks carry no entity metadata; dotted-hostname required to
  avoid flagging fragments like 'https://' alone)
- M3: depth budget (64) on walkers; over-budget nesting is fail-closed
  for has_rich_buttons (only craftable, never client-composed)

Monitor:
- M5: truncate classifier input to 8000 chars
- M9: light normalization (NFKC + zero-width/bidi strip) before
  classification

Tests: e2e ai_spam_filter on rich-only updates, classify truncation,
is_automatic_forward on the legacy keyboard path, login_url/web_app
extraction, credit-nested button URLs, deep-nesting behavior,
NO_RESTRICT template path, admin exemption, bio-bait on rich text,
rich duplicate tracking. L4/L5/L15/L16 addressed.
…lete+restrict)

Per policy decision: forwarders of rich-button messages keep
delete+restrict (same as the inline-keyboard rule; the incident
spammer was a forwarder). The notification now reads 'Pesan terusan'
for forwarded messages and carries an appeal line ('Hubungi admin
grup') in both restricted and non-restricted variants.

_handle_group_spam gains an optional extra_format mapping for
template placeholders; property tests for _url_shaped_candidates
invariants (no-http => empty, well-formed candidates, no dupes).
The M3 depth budget removed the RecursionError fail-fast that used
to terminate walks over infinitely-generative objects (mocks):
with every attribute truthy, the walker branched ~15^64 and never
finished, OOMing the suite and hanging CI.

_walk/_visit now only descend into str/list/tuple or aiogram-module
objects (_is_rich_node); the depth + node budgets remain as
defense-in-depth for hostile aiogram-shaped payloads.
@rezhajulio
rezhajulio merged commit 6f360f1 into rezhajulio:main Oct 17, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants