feat: block non-whitelisted guest bot messages with progressive restriction - #26
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.pyGuestBotFilter— customMessageFilterthat matches only messages withguest_bot_caller_userorguest_bot_caller_chatset (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):Plugin wiring
guest_bot_blockathandler_group=1, registered beforeinline_keyboard_spamwithGuestBotFilterfor precise matchingguard_plugin("guest_bot_block")for per-group toggle controlblock=False) soApplicationHandlerStopproperly stops downstream groupsConfig
guest_bot_whitelistfield inSettings(withNoDecodeannotation for comma-separated env parsing) andGroupConfig(normalization validator)GUEST_BOT_WHITELISTenv var:GUEST_BOT_WHITELIST=@somebot,anotherbotgroups.jsonsupport:"guest_bot_whitelist": ["somebot", "anotherbot"]"plugins": {"guest_bot_block": false}in groups.jsonDB schema:
warning_kinddiscriminatorwarning_kindcolumn toUserWarningtable (default"profile", indexed)ALTER TABLE)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 bywarning_kindwarning_kind="guest_bot"— completely isolated from profile warningswarning_kind="profile"warnings (no time-based auto-restriction for guest bot violations)warning_kind="profile"restrictions — guest bot restrictions are admin-onlyIndonesian templates
GUEST_BOT_WARNING— warning for first violationGUEST_BOT_RESTRICTION— restriction notification (no DM appeal link, admin-only recovery)Files changed
src/bot/handlers/guest_bot.pytests/test_guest_bot.pysrc/bot/config.pyguest_bot_whitelistfield +NoDecode+ env parsersrc/bot/group_config.pyguest_bot_whitelistfield + normalization validatorsrc/bot/constants.pyGUEST_BOT_WARNING+GUEST_BOT_RESTRICTIONtemplatessrc/bot/database/models.pywarning_kindcolumn onUserWarningsrc/bot/database/service.pywarning_kindparam on all methods + migrationsrc/bot/plugins/definitions.pyguest_bot_blockin manifest (group=1, before inline_keyboard_spam)src/bot/plugins/builtin/spam.pyregister_guest_bot_block()withGuestBotFiltersrc/bot/plugins/manager.pyguest_bot_blockinto_REGISTRYtests/test_plugin_manager.pytests/test_main_plugins_bootstrap.pytests/test_group_config.pytests/test_config.py.env.exampleGUEST_BOT_WHITELISTgroups.json.exampleguest_bot_whitelistto both group examplesVerification
uv run pytest— 1048 passeduv run ruff check .— cleanuv run mypy src/bot/ tests/— clean