Skip to content

feat: block non-whitelisted guest bot messages with progressive restriction - #26

Merged
rezhajulio merged 7 commits into
mainfrom
feat/guest-bot-block
Aug 8, 2026
Merged

feat: block non-whitelisted guest bot messages with progressive restriction#26
rezhajulio merged 7 commits into
mainfrom
feat/guest-bot-block

Conversation

@rezhajulio

Copy link
Copy Markdown
Owner

Summary

Telegram Bot API 10.0 (May 2026) introduced Guest Mode — any user can @mention a bot in any group chat, and the bot posts a reply directly in the chat without being a member. This PR adds moderation to block unwanted guest bot messages with a whitelist and progressive restriction of the invoking user.

Changes

New handler: src/bot/handlers/guest_bot.py

  • GuestBotFilter — custom MessageFilter that matches only messages with guest_bot_caller_user or guest_bot_caller_chat set (PTB v22.8 native fields)
  • is_guest_bot_whitelisted() — case-insensitive username matching against per-group whitelist (strips @, lowercases)
  • handle_guest_bot_message() — deletes non-whitelisted guest bot messages and progressively restricts the invoking user (not the bot):
    • 1st violation → warning notification to warning topic
    • 2nd to (N-1) → silent increment
    • Nth violation → restrict (mute) + notification
    • Admins/trusted users are exempt (message deleted, no warning)
    • Channel callers are delete-only (no human to restrict)
    • Already-restricted callers skip new warning cycles

Plugin wiring

  • New plugin guest_bot_block at handler_group=1, registered before inline_keyboard_spam with GuestBotFilter for precise matching
  • Gated by guard_plugin("guest_bot_block") for per-group toggle control
  • Blocking handler (no block=False) so ApplicationHandlerStop properly stops downstream groups

Config

  • guest_bot_whitelist field in Settings (with NoDecode annotation for comma-separated env parsing) and GroupConfig (normalization validator)
  • GUEST_BOT_WHITELIST env var: GUEST_BOT_WHITELIST=@somebot,anotherbot
  • groups.json support: "guest_bot_whitelist": ["somebot", "anotherbot"]
  • Plugin toggle: "plugins": {"guest_bot_block": false} in groups.json

DB schema: warning_kind discriminator

  • Added warning_kind column to UserWarning table (default "profile", indexed)
  • SQLite migration for existing databases (ALTER TABLE)
  • All DB service methods (get_or_create_user_warning, increment_message_count, mark_user_restricted, is_user_restricted_by_bot, mark_user_unrestricted, delete_user_warnings, get_active_user_warning, get_warnings_past_time_threshold_for_group) accept and filter by warning_kind
  • Guest bot handler uses warning_kind="guest_bot" — completely isolated from profile warnings
  • Scheduler only processes warning_kind="profile" warnings (no time-based auto-restriction for guest bot violations)
  • DM/verify unrestriction only lifts warning_kind="profile" restrictions — guest bot restrictions are admin-only

Indonesian templates

  • GUEST_BOT_WARNING — warning for first violation
  • GUEST_BOT_RESTRICTION — restriction notification (no DM appeal link, admin-only recovery)

Files changed

File Change
src/bot/handlers/guest_bot.py New — handler + filter
tests/test_guest_bot.py New — 17 tests
src/bot/config.py guest_bot_whitelist field + NoDecode + env parser
src/bot/group_config.py guest_bot_whitelist field + normalization validator
src/bot/constants.py GUEST_BOT_WARNING + GUEST_BOT_RESTRICTION templates
src/bot/database/models.py warning_kind column on UserWarning
src/bot/database/service.py warning_kind param on all methods + migration
src/bot/plugins/definitions.py guest_bot_block in manifest (group=1, before inline_keyboard_spam)
src/bot/plugins/builtin/spam.py register_guest_bot_block() with GuestBotFilter
src/bot/plugins/manager.py Wired guest_bot_block into _REGISTRY
tests/test_plugin_manager.py Updated expected manifest order
tests/test_main_plugins_bootstrap.py Added registrar + filter tests
tests/test_group_config.py 5 whitelist normalization tests
tests/test_config.py 2 env parsing tests
.env.example Documented GUEST_BOT_WHITELIST
groups.json.example Added guest_bot_whitelist to both group examples

Verification

  • uv run pytest1048 passed
  • uv run ruff check . — clean
  • uv run mypy src/bot/ tests/ — clean

rezhajulio and others added 7 commits August 2, 2026 17:12
…iction

Telegram Bot API 10.0 (May 2026) introduced Guest Mode, allowing any
user to @mention a bot in any group chat without the bot being a member.
The bot posts a reply directly in the chat. This feature adds moderation
for those guest bot messages.

New handler (src/bot/handlers/guest_bot.py):
- GuestBotFilter: custom MessageFilter matching only guest bot messages
  (guest_bot_caller_user or guest_bot_caller_chat set)
- is_guest_bot_whitelisted: case-insensitive username matching against
  per-group whitelist (strips @, lowercases)
- handle_guest_bot_message: deletes non-whitelisted guest bot messages
  and progressively restricts the invoking user (not the bot) using the
  existing UserWarning state machine with a new warning_kind=guest_bot
  discriminator — 1st violation: warning, 2nd to (N-1): silent increment,
  Nth: restrict + notification. Admins/trusted users are exempt. Channel
  callers are delete-only (no human to restrict). Already-restricted
  callers skip new warning cycles.

Plugin wiring:
- New plugin guest_bot_block registered at group=1, before
  inline_keyboard_spam, with GuestBotFilter for precise matching
- Gated by guard_plugin(guest_bot_block) for per-group toggle control
- Blocking handler (no block=False) so ApplicationHandlerStop works

Config:
- guest_bot_whitelist field in Settings (NoDecode annotation for
  comma-separated env parsing) and GroupConfig (normalization validator)
- GUEST_BOT_WHITELIST env var and groups.json support
- Plugin toggle via plugins map in groups.json

DB schema:
- Added warning_kind column to UserWarning (default profile)
- SQLite migration for existing databases
- All DB service methods accept and filter by warning_kind
- Scheduler only processes profile warnings (not guest bot)
- DM/verify unrestriction only lifts profile restrictions

Tests: 1048 passed, ruff clean, mypy clean
Add Guest Mode blocking to AGENTS.md and README.md: architecture section (GuestBotFilter, handler group, progressive enforcement, separate warning_kind, whitelist config, plugin toggle), Where to Look / Code Map / Structure tree entries, README feature bullet and dedicated section, GUEST_BOT_WHITELIST in config table, updated test stats, database conventions for warning_kind discriminator and migration.
…ing, migration index

Oracle review fixes:

- High: Admin unrestrict and /verify now use is_user_restricted_by_bot_any_kind
  and mark_all_bot_restrictions_unrestricted to detect and clear any bot
  restriction regardless of warning_kind. DM flow clears all bot restriction
  flags after Telegram unmute. Guest-only restrictions remain ineligible
  for DM self-service (user sees 'no bot restriction', must contact admin).
  Mixed profile+guest restrictions are fully cleared by both admin and DM
  paths since Telegram has a single physical restriction state.

- Medium: Guest bot handler now checks threshold BEFORE first-warning,
  making the conditions mutually exclusive. warning_threshold==1
  restricts on first violation without sending a contradictory warning.

- Medium: Added DB-level tests for warning_kind isolation (profile vs
  guest_bot coexistence, increment/restrict/delete independence, scheduler
  query exclusion), cross-kind restriction methods, migration from old DB
  without warning_kind column, migration idempotency, and threshold==1.

- Low: Narrowed except Exception to except TelegramError for all Telegram
  API calls in guest_bot.py. Split restrict/DB-mark/notify into separate
  stages so DB errors are not swallowed by API error handling.

- Low: Migration now adds warning_kind as NOT NULL DEFAULT 'profile'
  and creates a composite index on (user_id, group_id, warning_kind,
  is_restricted) matching actual query patterns.
… stale docs

- Reorder verify_user_in_group to check restriction status BEFORE
  deleting warning records, so Telegram unrestrict actually fires
  for restricted users (was always returning False due to prior deletion)
- Return UNRESTRICT_FAILED_MESSAGE and preserve restriction records
  when Telegram unrestrict fails during /verify
- Add per-(group_id, user_id) asyncio.Lock (restriction_lock) shared
  across guest_bot handler, scheduler, DM, and verify to serialize
  Telegram API calls + DB state transitions
- Replace single-column warning_kind index with composite
  ix_user_warnings_kind (user_id, group_id, warning_kind, is_restricted)
  via __table_args__; migration's CREATE INDEX IF NOT EXISTS covers
  upgraded databases
- Update AGENTS.md: 27→28 plugins, _migrate_warning_kind→_migrate_user_warnings
- Add TestVerifyUserInGroup: profile-only, guest-only, mixed,
  no-restriction, unrestrict-failure, group-not-found lifecycle tests
- Add test_restriction_lock.py: serialization and concurrency tests
High: Expand restriction_lock to cover decisions + cleanup, not just
the API call. All six restriction/unrestriction paths now acquire the
lock before the final DB ownership/eligibility check and release after
the DB transition:
- verify_user_in_group: any-kind check + unrestrict + delete inside lock
- unrestrict_user_in_group: ownership check + unrestrict inside lock
- guest_bot handler: re-check restricted + re-fetch record under lock
- scheduler: re-check active warning under lock before restricting
- DM handler: recheck bot restriction inside lock before unrestrict
- message.py profile restriction: lock wraps restrict + mark
- captcha callback: lock wraps unrestrict
- captcha_recovery: lock wraps get_or_create + mark_restricted

Medium: Make add_photo_verification_whitelist idempotent in
verify_user_in_group (catch ValueError, continue). Broaden exception
catch to include Forbidden, NetworkError, TimedOut alongside BadRequest.

Low: Add index regression tests for fresh and migrated databases
asserting PRAGMA index_list + index_info for ix_user_warnings_kind.

Low: Fix README pipeline diagram to include guest_bot_block branch.
Fix .env.example to describe progressive warning/restriction behavior.
- Move guest_bot_block from float group=0.9 to int group=0 (PTB v20+ requires int)
  Fixes: TypeError on bot startup when registering handlers

- Fix restrict failure infinite loop: change threshold check from < to !=
  Was: if fresh.message_count < threshold → always retries on failure
  Now: if fresh.message_count != threshold → only restricts at exact threshold
  Fixes: Looping restrict_chat_member calls when API fails

- Extend captcha lock through DB finalization, call mark_all_bot_restrictions_unrestricted
  Serializes Telegram unrestrict + DB state transition per lock contract
  Handles nested exception: ApplicationHandlerStop propagates to outer handler

- Add try/except for restrict_chat_member failures (increment counter on failure)
  Prevents counter desync when restriction API calls fail

- Code quality: drop redundant None check, simplify whitelist normalization
- Tests: update expected handler group (0 instead of 0.9), manifest order assertion
- Types: revert PluginManifest to str|int (no float needed with group=0)

All 1075 tests passing, 97% coverage, ruff/mypy clean.
…ck bug

Critical fixes from 4-agent parallel review of feat/guest-bot-block:

- guest_bot.py: revert threshold check to < (not !=) and drop the
  increment-on-failure. != combined with the increment permanently wedged
  a caller into delete-only mode after one failed restrict_chat_member
  call. < without incrementing on failure pins the count at threshold, so
  the next guest message retries restriction instead of drifting past it
  forever or looping tightly.

- captcha_recovery.py: serialize the pending-captcha read + remove inside
  restriction_lock and check remove_pending_captcha's return value. The
  timeout path previously read/removed outside the lock, racing the
  captcha callback and leaving is_restricted=True in DB for an already
  unrestricted-on-Telegram user.

- message.py, scheduler.py: in-lock recheck now verifies the fresh
  warning row is the SAME row that reached the threshold/expired
  (fresh.id != record.id), not just that some active row exists. Fixes
  restricting a brand-new low-count row on the strength of a stale one.

- restriction_lock.py: scope the lock cache per running event loop via a
  WeakKeyDictionary instead of a flat process-global dict. asyncio.Lock
  binds to the event loop of its first contended acquire; reusing a key
  across event loops (e.g. per-test loops) previously raised RuntimeError.

- verify.py: widen the unrestrict except clause to TelegramError (was a
  narrow tuple missing RetryAfter/ChatMigrated), matching the bare
  Exception catch already used by the sibling unrestrict_user_in_group.

- guest_bot.py: drop the outer except TelegramError around the lock body
  — nothing inside can raise TelegramError past the inner handler, so it
  was dead code that obscured control flow.

- tests/test_scheduler.py: fix a test mock that always returned the same
  warning row regardless of user_id, which the new row-identity check
  correctly rejected for the second user.

- tests/test_restriction_lock.py: update for the loop-scoped lock storage.

- AGENTS.md, README.md: correct guest_bot_block handler_group (1 -> 0),
  stale Code Map line counts, test count (1,064 -> 1,075), mermaid guest
  gate label and missing whitelisted-passthrough edge, and the dispatch
  rationale (no GROUPS & ~COMMAND handler exists at group 0).

All 1075 tests passing, ruff/mypy clean.
@rezhajulio
rezhajulio merged commit 4697206 into main Aug 8, 2026
5 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.

1 participant