Skip to content
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ BIO_BAIT_MONITOR_ONLY=false
# Example: PLUGINS_DEFAULT={"captcha":true,"dm":false}
# PLUGINS_DEFAULT={"captcha":true,"dm":false}

# Guest Bot Whitelist (Telegram Guest Mode - Bot API 10.0)
# Comma-separated list of bot usernames allowed to post guest messages
# Messages from non-whitelisted guest bots are deleted and non-exempt
# human callers are progressively warned/restricted (admins/trusted and
# channel-only callers are delete-only)
# Usernames are case-insensitive, @ prefix is optional
# Example: GUEST_BOT_WHITELIST=@somebot,anotherbot
GUEST_BOT_WHITELIST=

# Logfire Configuration (optional - for production logging)
# Get your token from https://logfire.pydantic.dev
LOGFIRE_TOKEN=your_logfire_token_here
Expand Down
49 changes: 36 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ PythonID/
│ │ ├── trust.py # /trust, /untrust, /trusted admin commands
│ │ ├── warn.py # Admin /warn command (reply or user ID)
│ │ ├── duplicate_spam.py # Duplicate message detection
│ │ └── bio_bait.py # Bio-bait spam (bait phrases + suspicious profile bio links)
│ │ ├── bio_bait.py # Bio-bait spam (bait phrases + suspicious profile bio links)
│ │ └── guest_bot.py # Guest Mode moderation (delete + progressive restriction)
│ ├── services/
│ │ ├── user_checker.py # Profile validation (photo + username)
│ │ ├── scheduler.py # JobQueue auto-restriction (every 5 min)
Expand All @@ -80,7 +81,7 @@ PythonID/
│ │ └── admin_cache.py # Admin ID cache + refresh
│ └── database/
│ ├── models.py # SQLModel schemas (5 tables: UserWarning, PhotoVerificationWhitelist, PendingCaptchaValidation, NewUserProbation, TrustedUser)
│ └── service.py # DatabaseService singleton (645 lines)
│ └── service.py # DatabaseService singleton (958 lines)
├── tests/ # pytest-asyncio + Hypothesis (30+ files)
│ ├── test_properties.py # Property-based tests for pure functions
│ └── test_warn.py # /warn command tests (23 tests)
Expand All @@ -101,38 +102,40 @@ PythonID/
| Add Telegram whitelist | `constants.py` → `WHITELISTED_TELEGRAM_PATHS` | Lowercase, exact path match |
| Multi-group config | `group_config.py` | GroupConfig model, GroupRegistry, groups.json loading |
| Warn a member | `handlers/warn.py` + `plugins/builtin/commands.py` | Admin `/warn` by reply or user ID; registered as `warn_command` |
| Block guest bots | `handlers/guest_bot.py` + `plugins/builtin/spam.py` | `guest_bot_block` plugin (group=0); whitelist via `GUEST_BOT_WHITELIST` env or `guest_bot_whitelist` per-group JSON |

## Code Map (Key Files)

| File | Lines | Role |
|------|-------|------|
| `database/service.py` | 850 | **Complexity hotspot** - handles warnings, captcha, probation state |
| `database/service.py` | 958 | **Complexity hotspot** - handles warnings, captcha, probation state |
| `constants.py` | 724 | Templates + massive whitelists (Indonesian tech community) |
| `handlers/anti_spam.py` | 494 | Anti-spam: contact cards, inline keyboards, probation enforcement |
| `handlers/bio_bait.py` | 441 | Bio-bait spam: obfuscated bait phrases + suspicious profile bio links |
| `handlers/guest_bot.py` | 133 | Guest Mode moderation: delete non-whitelisted guest bot messages + progressive restriction of the human caller |
| `handlers/check.py` | 437 | Admin /check: group selector + group-scoped action buttons |
| `handlers/captcha.py` | 427 | New member join → restrict → verify (with profile check) → unrestrict lifecycle |
| `handlers/verify.py` | 400 | Photo exemption + bot-owned unrestriction (group-scoped) |
| `handlers/verify.py` | 422 | Photo exemption + bot-owned unrestriction (group-scoped) |
| `handlers/trust.py` | 368 | /trust, /untrust, /trusted admin commands (no auto-unrestrict) |
| `handlers/dm.py` | 250 | DM unrestriction flow with deep-link group recovery |
| `handlers/message.py` | 208 | Profile compliance monitoring + stale warning clearing |
| `handlers/dm.py` | 264 | DM unrestriction flow with deep-link group recovery |
| `handlers/message.py` | 217 | Profile compliance monitoring + stale warning clearing |
| `handlers/status.py` | 181 | Group-scoped /status (admin's groups only, Indonesian labels) |
| `handlers/warn.py` | 170 | Admin-issued generic warning by reply or user ID; optional moderation-topic routing |
| `services/scheduler.py` | 151 | Auto-restriction with pre-restriction profile recheck |
| `services/scheduler.py` | 160 | Auto-restriction with pre-restriction profile recheck |
| `group_config.py` | 255 | Multi-group config, registry, JSON loading, .env fallback |
| `main.py` | 191 | Entry point, logging, post_init, PluginManager bootstrap |
| `plugins/manager.py` | 188 | PluginManager — static registry + deterministic registration order |
| `plugins/config.py` | 156 | `guard_plugin` runtime gate + toggle resolution |
| `plugins/definitions.py` | 72 | `MANIFEST_ORDER` / `PLUGIN_NAMES` — single source of truth for plugin names + groups |
| `plugins/builtin/commands.py` | 166 | Wraps all command + callback handlers with group-scoped patterns |
| `plugins/builtin/spam.py` | 93 | Wraps all 5 anti-spam handlers with `guard_plugin` |
| `plugins/builtin/spam.py` | 114 | Wraps all 5 anti-spam handlers + guest_bot_block with `guard_plugin` |
| `plugins/builtin/captcha.py` | 43 | Wraps captcha handler + applies guard_plugin gating |

## Architecture Patterns

### Modular Plugin System
- Built-in plugins live in `src/bot/plugins/builtin/`, one per handler domain (captcha, spam, topic_guard, profile_monitor, commands, dm, jobs)
- `plugins/definitions.py` holds `MANIFEST_ORDER` — a static, hand-maintained tuple of 27 plugin names (topic_guard first, job plugins last) that is the single source of truth for registration order and for the group number each plugin runs in
- `plugins/definitions.py` holds `MANIFEST_ORDER` — a static, hand-maintained tuple of 28 plugin names (topic_guard first, job plugins last) that is the single source of truth for registration order and for the group number each plugin runs in
- `PluginManager.register_all()` (called from `main.py:main`, not `post_init`) walks `MANIFEST_ORDER` against a static `_REGISTRY` dict (name → registrar function) and stores results in `application.bot_data["plugin_handlers"]`
- The plugin wrapper pattern: `bot.plugins.builtin.X` imports from `bot.handlers.X`, clones the handler list, and applies `guard_plugin("X")` for per-group runtime gating
- To add a new plugin: add a `register_*(application) -> list[BaseHandler]` function in `builtin/`, add its name + group to `_PLUGIN_DEFINITIONS` in `definitions.py`, wire it into `_REGISTRY` in `manager.py`
Expand All @@ -145,7 +148,7 @@ PythonID/
```python
# Registration order comes from MANIFEST_ORDER (plugins/definitions.py), not main.py directly
group=-1 # topic_guard: Runs FIRST
group=0 # commands (including warn_command), callbacks, captcha, dm (18 plugins, order-independent)
group=0 # commands (including warn_command), callbacks, captcha, dm, guest_bot_block (19 plugins; only guest_bot_block is order-sensitive, via ApplicationHandlerStop)
group=1 # inline_keyboard_spam: Catches inline keyboard URL spam
group=2 # contact_spam: Blocks contact card sharing
group=3 # new_user_spam: Probation enforcement (links/forwards)
Expand Down Expand Up @@ -174,6 +177,24 @@ group=6 # JobQueue only (not a handler group): auto_restrict_job, refresh_admi
- `bio_bait_monitor_only` (per-group) skips delete/restrict and only logs + optionally alerts `bio_bait_alert_chat_id` — use this to tune detection before enforcing
- Admins and trusted users are exempt (`is_user_admin_or_trusted`)

### Guest Bot Moderation
- `handlers/guest_bot.py` blocks Telegram **Guest Mode** messages — messages posted by a bot on behalf of a user/channel via the `@` mention feature that Telegram routes through `message.guest_bot_caller_user` / `message.guest_bot_caller_chat` (PTB v22.8+)
- A custom `GuestBotFilter` (`filters.MessageFilter`) matches only messages where either guest-bot caller field is set, so the handler only fires on actual Guest Mode updates and raises `ApplicationHandlerStop` to stop the spam handlers at groups 1-5 from also processing the message
- Registered as `guest_bot_block` at `handler_group=0` (alongside commands/callbacks/captcha/dm) via `spam_mod.register_guest_bot_block` in `plugins/builtin/spam.py`, gated by `guard_plugin("guest_bot_block")`
- Non-whitelisted guest bot messages are always deleted; the invoking **human caller** then receives progressive enforcement using the group's existing `warning_threshold`:
- 1st violation → warning in the warning topic (`GUEST_BOT_WARNING`)
- 2nd to (N-1) → silent increment
- Nth violation → restrict + notification (`GUEST_BOT_RESTRICTION`)
- If `restrict_chat_member` fails (e.g. bot lacks ban rights), the strike count is NOT incremented past the threshold — the next guest message from the same caller retries the restriction instead of drifting into a permanent delete-only state
- Admin/trusted callers have their guest message deleted but are **not** warned or restricted
- Chat/channel-only callers (no `guest_bot_caller_user`) are delete-only — there is no human to warn
- Already guest-bot-restricted callers do not start a fresh warning cycle (`is_user_restricted_by_bot` check)
- Guest strikes use a **separate DB warning kind** (`warning_kind="guest_bot"`) so they do not mix with profile-compliance (`"profile"`) warnings — `UserWarning.warning_kind` column, auto-migrated via `ALTER TABLE`
- Guest restrictions are **not** routed through the profile-compliance self-service DM unrestriction flow — a user restricted only for guest_bot violations gets "no bot restriction" from the DM flow and must contact an admin. However, if a user has **both** profile and guest_bot restrictions, the DM flow will unrestrict and clear **all** bot restriction flags (since Telegram has a single physical restriction state)
- Admin "Buka pembatasan bot" (unrestrict) and /verify use `is_user_restricted_by_bot_any_kind` + `mark_all_bot_restrictions_unrestricted` — they detect and clear **any** bot-applied restriction regardless of `warning_kind`
- Whitelist: bot usernames compared case-insensitively, optional `@` prefix; configured via `GUEST_BOT_WHITELIST` env (comma-separated) or `guest_bot_whitelist` per-group JSON list
- Disable per group: `"plugins": {"guest_bot_block": false}` in `groups.json`

### Topic Guard Design
- Handles both `message` and `edited_message` updates (combined filter)
- Raises `ApplicationHandlerStop` after handling ANY warning-topic message (allows or deletes)
Expand All @@ -195,7 +216,7 @@ group=6 # JobQueue only (not a handler group): auto_restrict_job, refresh_admi
- Handler + JobQueue registration (`PluginManager.register_all()`) and effective-plugin-map computation happen later, in `main()` after `post_init` is wired up but before `run_polling` — not inside `post_init` itself

### Multi-Group Support
- `GroupConfig` — Pydantic model with 21 per-group settings: warning thresholds, captcha, probation, contact/duplicate/bio-bait spam tuning, `rules_link`, optional `moderation_topic_id`, and a `plugins: dict[str, bool] | None` override
- `GroupConfig` — Pydantic model with 22 per-group settings: warning thresholds, captcha, probation, contact/duplicate/bio-bait spam tuning, `rules_link`, optional `moderation_topic_id`, `guest_bot_whitelist`, and a `plugins: dict[str, bool] | None` override
- `GroupRegistry` — O(1) lookup by group_id, manages all monitored groups
- `groups.json` — Per-group config file; falls back to `.env` for single-group mode (missing fields default from `GroupConfig.model_fields`)
- `get_group_config_for_update()` — Helper to resolve config for incoming Telegram updates
Expand All @@ -213,7 +234,8 @@ Time threshold → Auto-restrict via scheduler (parallel path)
- SQLite with **WAL mode + `synchronous=NORMAL`** for write concurrency under a single-process bot
- `session.exec(select(Model).where(...)).first()` syntax
- Atomic updates for violation counts via raw `UPDATE ... SET x = x + 1` (prevents read-modify-write races)
- No Alembic — use `SQLModel.metadata.create_all` + `_migrate_trusted_users` (`ALTER TABLE`) for column adds
- No Alembic — use `SQLModel.metadata.create_all` + `_migrate_trusted_users` (`ALTER TABLE`) for column adds; `_migrate_user_warnings` adds the `warning_kind` column to `user_warnings` for the guest-bot feature
- `UserWarning.warning_kind` discriminates warning sources: `"profile"` (profile-compliance monitor) vs `"guest_bot"` (Guest Mode moderation). All `DatabaseService` warning methods accept a `warning_kind` parameter so guest strikes never mix with profile strikes
- Registers a datetime SQLite adapter to isoformat strings (avoids the Python 3.12+ default-adapter deprecation)
- New tables: `TrustedUser` (5th table) for the /trust admin bypass feature — `group_id` defaults to `0` (global scope); per-group trust is modeled in the schema but not currently exercised anywhere

Expand Down Expand Up @@ -294,7 +316,7 @@ if user.id not in admin_ids:

## Notes

- Registration order for all 27 built-in plugins lives in `MANIFEST_ORDER` (`plugins/definitions.py`), not scattered across `main.py`
- Registration order for all 28 built-in plugins lives in `MANIFEST_ORDER` (`plugins/definitions.py`), not scattered across `main.py`
- `duplicate_spam` and `bio_bait_spam` both run at `group=4`; `auto_restrict_job` / `refresh_admin_ids_job` run as JobQueue jobs tagged `group=6` (not a PTB handler group)
- Topic guard runs at `group=-1` to intercept unauthorized messages BEFORE other handlers
- Topic guard handles both messages and edited messages, raises `ApplicationHandlerStop` to block downstream handlers
Expand All @@ -310,6 +332,7 @@ if user.id not in admin_ids:
- **Trust feature**: `TrustedUser` table caches user_full_name + admin_full_name at trust time so `/trusted` lists admin info without Telegram API calls. Backfill script at `scripts/backfill_trusted_names.py` for pre-existing rows
- **Local review artifacts**: `reviews/` directory contains output from parallel reviewer subagents. Gitignored; not part of the source tree
- **Captcha DB ordering**: The captcha callback handler calls Telegram `unrestrict_user` BEFORE DB writes (remove_pending_captcha, start_new_user_probation). If unrestrict fails, the pending captcha stays in DB and the user can retry. DB finalization is idempotent — `remove_pending_captcha` returning False means a concurrent callback already finalized
- **Guest bot blocking**: Telegram Guest Mode lets any user `@mention` a bot and have the result posted in a chat. The `guest_bot_block` plugin (group=0) deletes non-whitelisted guest bot messages and progressively restricts the human caller (1st=warning, Nth=restrict). Admins/trusted users and channel-only callers are delete-only. Bot whitelist is case-insensitive with optional `@`. A failed restriction does not increment the strike count past the threshold, so the caller's next guest message retries the restriction. Guest strikes are tracked separately via `warning_kind="guest_bot"` and are not eligible for the DM self-service unrestriction flow

## Policy

Expand Down
Loading
Loading