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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ BIO_BAIT_MONITOR_ONLY=false
# Example: 57747812
# BIO_BAIT_ALERT_CHAT_ID=57747812

# Topic ID for admin-issued /warn moderation messages (optional)
# When set, /warn sends warnings to this topic instead of the main group chat.
# Example: 456
# MODERATION_TOPIC_ID=456

# Path to groups.json for multi-group support (optional)
# If this file exists, per-group settings are loaded from it instead of the
# GROUP_ID/WARNING_TOPIC_ID/etc. fields above. See groups.json.example.
Expand Down
17 changes: 11 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ PythonID/
├── src/bot/
│ ├── main.py # Entry point + handler registration (priority groups!)
│ ├── config.py # Pydantic settings (get_settings() cached)
│ ├── constants.py # Indonesian templates + URL whitelists (528 lines)
│ ├── constants.py # Indonesian templates + URL whitelists (739 lines)
│ ├── group_config.py # Multi-group config (GroupConfig, GroupRegistry)
│ ├── plugins/ # Modular plugin system (wraps handlers)
│ │ ├── manager.py # PluginManager — discovers + registers built-ins
Expand All @@ -68,6 +68,7 @@ PythonID/
│ │ ├── dm.py # DM unrestriction flow
│ │ ├── topic_guard.py # Warning topic protection (group=-1)
│ │ ├── 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)
│ ├── services/
Expand All @@ -81,7 +82,8 @@ PythonID/
│ ├── models.py # SQLModel schemas (5 tables: UserWarning, PhotoVerificationWhitelist, PendingCaptchaValidation, NewUserProbation, TrustedUser)
│ └── service.py # DatabaseService singleton (645 lines)
├── tests/ # pytest-asyncio + Hypothesis (30+ files)
│ └── test_properties.py # Property-based tests for pure functions
│ ├── test_properties.py # Property-based tests for pure functions
│ └── test_warn.py # /warn command tests (23 tests)
├── scripts/
│ └── backfill_trusted_names.py # One-shot backfill for trusted user names
└── data/bot.db # SQLite (auto-created, WAL mode)
Expand All @@ -98,6 +100,7 @@ PythonID/
| Add URL whitelist | `constants.py` → `WHITELISTED_URL_DOMAINS` | Suffix-based matching |
| 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` |

## Code Map (Key Files)

Expand All @@ -114,6 +117,7 @@ PythonID/
| `handlers/dm.py` | 250 | DM unrestriction flow with deep-link group recovery |
| `handlers/message.py` | 208 | 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 |
| `group_config.py` | 255 | Multi-group config, registry, JSON loading, .env fallback |
| `main.py` | 191 | Entry point, logging, post_init, PluginManager bootstrap |
Expand All @@ -128,7 +132,7 @@ PythonID/

### 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 25 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 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
- `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 @@ -141,7 +145,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, verify/unverify/check/trust callbacks, captcha, dm (14 plugins, order-independent)
group=0 # commands (including warn_command), callbacks, captcha, dm (18 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)
Expand Down Expand Up @@ -191,7 +195,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 20 per-group settings: warning thresholds, captcha, probation, contact/duplicate/bio-bait spam tuning, `rules_link`, and a `plugins: dict[str, bool] | None` override
- `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
- `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 Down Expand Up @@ -290,7 +294,7 @@ if user.id not in admin_ids:

## Notes

- Registration order for all 25 built-in plugins lives in `MANIFEST_ORDER` (`plugins/definitions.py`), not scattered across `main.py`
- Registration order for all 27 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 @@ -302,6 +306,7 @@ if user.id not in admin_ids:
- Captcha callback data encodes group_id: `captcha_verify_{group_id}_{user_id}` to avoid ambiguity
- Scheduler iterates all groups with per-group exception isolation
- DM handler scans all groups in registry for user membership and unrestriction
- **Warn command**: A per-group admin can reply with `/warn [reason]` or use `/warn USER_ID [reason]`. The command is deleted before network lookups to protect the admin's identity; non-admins, bots, and self-targets are silently ignored. ID mode verifies membership with `get_chat_member`, reasons are Markdown-escaped, and the warning is sent to `moderation_topic_id` when configured or the main group otherwise. `moderation_topic_id` is distinct from `warning_topic_id`, which is used for bot logging. This command creates no DB record and is not gated by `guard_plugin`
- **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
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ A comprehensive Telegram bot for managing group members with profile verificatio
- **/verify command**: Whitelist users with hidden profile pictures (DM only)
- **/unverify command**: Remove users from verification whitelist (DM only)
- **Inline verification**: Forward messages to bot for quick verify/unverify buttons
- **/warn command**: Admin-issued bot warnings to in-group members by replying with `/warn [reason]` or using `/warn USER_ID [reason]`; optional reasons are Markdown-escaped, and the admin's command is deleted to protect their identity. Authorization is per-group, ID targets are checked for active membership, and bots, self-targets, and non-admin callers are silently ignored. Warnings go to `moderation_topic_id` when configured, otherwise to the main group chat, and do not create progressive-enforcement database records.
- **/trust command**: Add trusted users (DM only, supports user ID or forwarded message)
- **/untrust command**: Remove trusted users from trusted list (DM only)
- **/trusted command**: List all trusted users (DM only)
Expand Down Expand Up @@ -124,6 +125,7 @@ Add `GROUPS_CONFIG_PATH=groups.json` to your `.env` file, then edit `groups.json
{
"group_id": -1001234567890,
"warning_topic_id": 123,
"moderation_topic_id": null,
"restrict_failed_users": false,
"warning_threshold": 3,
"warning_time_threshold_minutes": 180,
Expand All @@ -136,6 +138,7 @@ Add `GROUPS_CONFIG_PATH=groups.json` to your `.env` file, then edit `groups.json
{
"group_id": -1009876543210,
"warning_topic_id": 456,
"moderation_topic_id": null,
"restrict_failed_users": true,
"warning_threshold": 5,
"warning_time_threshold_minutes": 60,
Expand All @@ -148,7 +151,7 @@ Add `GROUPS_CONFIG_PATH=groups.json` to your `.env` file, then edit `groups.json
]
```

When `groups.json` is present, per-group settings override the `.env` defaults. Each group can have its own warning thresholds, captcha settings, probation rules, and rules link. Each group entry can also add a `"plugins": {"bio_bait_spam": false}`-style object to disable specific built-in plugins just for that group, overriding the bot-wide `PLUGINS_DEFAULT`.
When `groups.json` is present, per-group settings override the `.env` defaults. Each group can have its own warning thresholds, moderation topic (`moderation_topic_id`), captcha settings, probation rules, and rules link. Each group entry can also add a `"plugins": {"bio_bait_spam": false}`-style object to disable specific built-in plugins just for that group, overriding the bot-wide `PLUGINS_DEFAULT`.

**Backward compatibility**: If no `groups.json` is configured (i.e., `GROUPS_CONFIG_PATH` is not set), the bot falls back to single-group mode using `GROUP_ID`, `WARNING_TOPIC_ID`, and other settings from `.env`.

Expand Down Expand Up @@ -262,6 +265,7 @@ PythonID/
│ ├── test_trust_handler.py
│ ├── test_user_checker.py
│ ├── test_verify_handler.py
│ ├── test_warn.py
│ └── test_whitelist.py
└── src/
└── bot/
Expand Down Expand Up @@ -289,6 +293,7 @@ PythonID/
│ ├── topic_guard.py # Warning topic protection
│ ├── trust.py # /trust, /untrust, /trusted admin commands
│ ├── verify.py # /verify and /unverify command handlers
│ ├── warn.py # Per-group admin /warn command
│ ├── duplicate_spam.py # Duplicate message detection
│ └── bio_bait.py # Bio-bait spam (bait phrases + suspicious profile bio links)
├── database/
Expand Down Expand Up @@ -583,6 +588,7 @@ The bot is organized into clear modules for maintainability:
- `verify.py`: /verify and /unverify command handlers
- `check.py`: /check command + forwarded message handling
- `trust.py`: /trust, /untrust, /trusted admin commands (TrustedUser table caches names at trust time so /trusted renders without API calls)
- `warn.py`: Per-group admin /warn messages by reply or member ID, routed to the configured moderation topic without database enforcement records
- **services/**: Business logic and utilities
- `scheduler.py`: JobQueue background job that runs every 5 minutes for time-based auto-restrictions
- `user_checker.py`: Profile validation (photo + username check) — used by both the captcha gate and the per-message monitor
Expand Down Expand Up @@ -673,6 +679,7 @@ When a restricted user DMs the bot (or sends `/start`):
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Required |
| `GROUP_ID` | Group ID to monitor (negative number) | Required |
| `WARNING_TOPIC_ID` | Topic ID for warning messages | Required |
| `MODERATION_TOPIC_ID` | Topic ID for admin /warn moderation messages (optional) | None |
| `RESTRICT_FAILED_USERS` | Enable progressive restriction mode | `false` |
| `WARNING_THRESHOLD` | Messages before restriction (message-based) | `3` |
| `WARNING_TIME_THRESHOLD_MINUTES` | Minutes before auto-restriction (time-based) | `180` (3 hours) |
Expand Down
2 changes: 2 additions & 0 deletions groups.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"bio_bait_enabled": true,
"bio_bait_monitor_only": false,
"bio_bait_alert_chat_id": null,
"moderation_topic_id": null,
"plugins": {
"captcha": false,
"dm": true,
Expand All @@ -45,6 +46,7 @@
"bio_bait_enabled": true,
"bio_bait_monitor_only": false,
"bio_bait_alert_chat_id": null,
"moderation_topic_id": null,
"plugins": {
"contact_spam": false,
"duplicate_spam": false,
Expand Down
2 changes: 2 additions & 0 deletions src/bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class Settings(BaseSettings):
bio_bait_enabled: bool = True
bio_bait_monitor_only: bool = False
bio_bait_alert_chat_id: int | None = None
moderation_topic_id: int | None = None
groups_config_path: str = "groups.json"
logfire_token: str | None = None
logfire_service_name: str = "pythonid-bot"
Expand Down Expand Up @@ -152,6 +153,7 @@ def model_post_init(self, __context):
"bio_bait_enabled",
"bio_bait_monitor_only",
"bio_bait_alert_chat_id",
"moderation_topic_id",
):
logger.debug(f"{field}: {getattr(self, field)}")
logger.debug(f"telegram_bot_token: {'***' + self.telegram_bot_token[-4:]}")
Expand Down
15 changes: 15 additions & 0 deletions src/bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,21 @@ def format_hours_display(hours: int) -> str:

ADMIN_WARN_SENT_MESSAGE = "✅ Peringatan telah dikirim ke {user_mention} di grup."

# Generic /warn command templates (admin-issued, in-group)
WARN_COMMAND_USAGE = (
"❌ Penggunaan: balas pesan member dengan /warn [alasan] "
"atau gunakan /warn USER_ID [alasan]"
)
WARN_COMMAND_NOT_FOUND = "❌ Tidak dapat menemukan user dengan ID {user_id}."
WARN_COMMAND_NOT_MEMBER = "❌ User {user_id} bukan member grup ini."
WARN_COMMAND_WITH_REASON = (
"⚠️ {user_mention}, kamu telah diperingatkan oleh admin: {reason}"
)
WARN_COMMAND_NO_REASON = (
"⚠️ {user_mention}, kamu telah diperingatkan oleh admin. "
"Mohon patuhi aturan grup."
)

TRUST_USER_ID_REQUIRED_MESSAGE = (
"❌ Penggunaan: /trust USER_ID atau /untrust USER_ID, atau forward pesan user ke bot."
)
Expand Down
1 change: 1 addition & 0 deletions src/bot/group_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class GroupConfig(BaseModel):
bio_bait_enabled: bool = True
bio_bait_monitor_only: bool = False
bio_bait_alert_chat_id: int | None = None
moderation_topic_id: int | None = None
plugins: dict[str, bool] | None = None

@field_validator("group_id")
Expand Down
Loading
Loading