Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 56 additions & 28 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ PythonID/
│ │ ├── dm.py # DM unrestriction flow
│ │ ├── topic_guard.py # Warning topic protection (group=-1)
│ │ ├── trust.py # /trust, /untrust, /trusted admin commands
│ │ └── duplicate_spam.py # Duplicate message detection
│ │ ├── duplicate_spam.py # Duplicate message detection
│ │ └── bio_bait.py # Bio-bait spam (bait phrases + suspicious profile bio links)
│ ├── services/
│ │ ├── user_checker.py # Profile validation (photo + username)
│ │ ├── scheduler.py # JobQueue auto-restriction (every 5 min)
Expand All @@ -90,7 +91,7 @@ PythonID/

| Task | Location | Notes |
|------|----------|-------|
| Add new handler | `main.py` | Register with appropriate group (-1, 0, 1-5) |
| Add new handler | `plugins/definitions.py` + `plugins/builtin/X.py` | Add to `MANIFEST_ORDER` with a `handler_group` (-1, 0-6); handlers register via `PluginManager`, not directly in `main.py` |
| Modify messages | `constants.py` | All Indonesian templates centralized |
| Add DB table | `database/models.py` → `database/service.py` | Add model, then service methods |
| Change config | `config.py` | Pydantic BaseSettings with env vars |
Expand All @@ -102,44 +103,65 @@ PythonID/

| File | Lines | Role |
|------|-------|------|
| `group_config.py` | 250 | Multi-group config, registry, JSON loading, .env fallback |
| `database/service.py` | 671 | **Complexity hotspot** - handles warnings, captcha, probation state |
| `constants.py` | 530 | Templates + massive whitelists (Indonesian tech community) |
| `handlers/captcha.py` | 375 | New member join → restrict → verify (with profile check) → unrestrict lifecycle |
| `handlers/verify.py` | 358 | Admin verification commands + inline button callbacks |
| `handlers/anti_spam.py` | 420 | Anti-spam: contact cards, inline keyboards, probation enforcement |
| `handlers/trust.py` | 350 | /trust, /untrust, /trusted admin commands + cache |
| `main.py` | 315 | Entry point, logging, handler registration, JobQueue setup |
| `plugins/manager.py` | 250 | PluginManager — discovers + registers all built-in plugins |
| `plugins/builtin/captcha.py` | 50 | Wraps captcha handler + applies guard_plugin gating |
| `database/service.py` | 813 | **Complexity hotspot** - handles warnings, captcha, probation state |
| `constants.py` | 666 | 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/captcha.py` | 412 | New member join → restrict → verify (with profile check) → unrestrict lifecycle |
| `handlers/trust.py` | 381 | /trust, /untrust, /trusted admin commands + cache |
| `handlers/verify.py` | 320 | Admin verification commands + inline button callbacks |
| `group_config.py` | 255 | Multi-group config, registry, JSON loading, .env fallback |
| `handlers/duplicate_spam.py` | 216 | Duplicate message detection |
| `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` | 69 | `MANIFEST_ORDER` / `PLUGIN_NAMES` — single source of truth for plugin names + groups |
| `plugins/builtin/spam.py` | 93 | Wraps all 5 anti-spam handlers 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)
- Each plugin's `register(application)` is called by `PluginManager` in `main.py:post_init`
- `plugins/definitions.py` holds `MANIFEST_ORDER` — a static, hand-maintained tuple of 23 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: create a class with `name` + `register(application)` in `builtin/`, register it in `manager.py`
- 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`
- Handler modules stay decoupled from plugin internals — changes to `bot/handlers/X.py` flow through transparently

### Handler Priority Groups
```python
# main.py - Order matters!
# Registration order comes from MANIFEST_ORDER (plugins/definitions.py), not main.py directly
group=-1 # topic_guard: Runs FIRST
group=0 # Commands, DM, captcha
group=0 # commands, verify/unverify/check/trust callbacks, captcha, dm (14 plugins, order-independent)
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)
group=4 # duplicate_spam: Repeated message detection
group=5 # message_handler: Runs LAST, profile compliance check
group=4 # duplicate_spam + bio_bait_spam: Repeated messages / bio-bait detection
group=5 # profile_monitor: Runs LAST, profile compliance check
group=6 # JobQueue only (not a handler group): auto_restrict_job, refresh_admin_ids_job
```

### Plugin Gating (`guard_plugin`)
- `guard_plugin("name")` (`plugins/config.py`) wraps a handler callback; on each update it looks up `context.bot_data["plugin_effective_map"][group_id]["name"]` and no-ops the callback if disabled
- Only gates **group/supergroup** chats — private and channel updates always pass through unchanged
- Fail-open by design: unknown group, missing plugin key, or missing effective map all resolve to enabled
- Per-group toggle resolution (`resolve_plugin_toggles`), first match wins: (1) `GroupConfig.plugins` override in groups.json, (2) `Settings.plugins_default` (`PLUGINS_DEFAULT` env, JSON object), (3) `True`
- `PluginManager.compute_effective_map()` runs once at startup (after `register_all`) and caches the resolved per-group map in `bot_data["plugin_effective_map"]` — it is not recomputed per-update
- There is no bypass mechanism for admin commands beyond simply not wrapping them in `guard_plugin`

### Captcha Profile Check
- New members must have a public profile photo AND username before captcha verification completes
- `check_user_profile()` in `services/user_checker.py` queries both via Bot API
- Profile-incomplete path: alert shown, captcha record preserved, timeout still armed, user can fix profile and retry
- DB finalization (remove_pending_captcha + start_new_user_probation) runs **before** `unrestrict_user` — the irreversible Telegram side effect goes last, so a failure leaves the user still restricted + DB consistent

### Bio Bait Detection
- `handlers/bio_bait.py` catches two vectors: (1) a bait phrase in the message itself ("cek bio aku"), (2) the sender's Telegram **profile bio** containing a promo/invite link — bio is fetched via `get_chat` and cached per-user for 1 hour (5 min on fetch failure) to avoid hammering the API
- Bait-phrase matching normalizes obfuscation first: NFKC + lowercase, strips zero-width characters, canonicalizes obfuscated "bio"/"byo" spellings (leetspeak, separators, Cyrillic look-alikes) before running the bait regexes
- `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`)

### 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 @@ -153,15 +175,17 @@ group=5 # message_handler: Runs LAST, profile compliance check
- `BotInfoCache` — Class-level cache for bot username/ID

### Admin Cache
- Fetched at startup in `post_init()` and stored in `bot_data["group_admin_ids"]` (per-group) and `bot_data["admin_ids"]` (union)
- Refreshed every 10 minutes via `refresh_admin_ids` JobQueue job
- `post_init()` order: `preload_admin_ids()` (fail-soft per group) → load all `TrustedUser` IDs into `bot_data["trusted_user_ids"]` → conditionally `recover_pending_captchas()` if any group has `captcha_enabled`
- Admin IDs stored in `bot_data["group_admin_ids"]` (per-group) and `bot_data["admin_ids"]` (union)
- Refreshed every 10 minutes via `refresh_admin_ids_job` JobQueue job
- On refresh failure for a group, falls back to existing cached data (not empty list)
- Spam handlers use cached admin IDs; topic_guard uses live `get_chat_member` API call
- 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 for per-group settings (warning thresholds, captcha, probation)
- `GroupConfig` — Pydantic model with 20 per-group settings: warning thresholds, captcha, probation, contact/duplicate/bio-bait spam tuning, `rules_link`, 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
- `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
- Exception-isolated loops — Per-group API calls wrapped in try/except to prevent cross-group failures

Expand All @@ -174,11 +198,12 @@ Time threshold → Auto-restrict via scheduler (parallel path)
```

### Database Conventions
- SQLite with **WAL mode** for concurrency
- 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 (prevents race conditions)
- No Alembic — use `SQLModel.metadata.create_all` + `_migrate_trusted_users` for column adds
- New tables: `TrustedUser` (5th table) for the /trust admin bypass feature
- 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
- 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

## Code Style

Expand Down Expand Up @@ -251,11 +276,14 @@ if user.id not in admin_ids:
| Add a new built-in plugin | `src/bot/plugins/builtin/X.py` (class with `name` + `register(application)`) |
| Register an existing handler as a plugin | Wrap `bot.handlers.X.get_handlers()` in `bot/plugins/builtin/X.py` |
| Add per-group runtime gating | `guard_plugin("X")` decorator in `src/bot/plugins/config.py` |
| Disable a plugin for one group | Set `enabled_plugins: list[str]` in that group's config (groups.json) |
| Bypass per-group gating for a single call | `guard_plugin.bypass_for(group_id)` context manager |
| Disable a plugin for one group | Set `plugins: {"name": false}` in that group's entry in groups.json (`GroupConfig.plugins`) |
| Set a bot-wide plugin default | `PLUGINS_DEFAULT` env var (JSON object, `Settings.plugins_default`) |
| Exempt a handler from gating entirely | Don't wrap it in `guard_plugin(...)` (this is how admin commands stay ungated) |

## Notes

- Registration order for all 23 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
- JobQueue auto-restriction job runs every 5 minutes (first run after 5 min delay)
Expand Down
Loading
Loading