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
30 changes: 19 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,31 +103,39 @@ PythonID/

| File | Lines | Role |
|------|-------|------|
| `database/service.py` | 813 | **Complexity hotspot** - handles warnings, captcha, probation state |
| `constants.py` | 666 | Templates + massive whitelists (Indonesian tech community) |
| `database/service.py` | 850 | **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/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 |
| `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/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/status.py` | 181 | Group-scoped /status (admin's groups only, Indonesian labels) |
| `services/scheduler.py` | 151 | Auto-restriction with pre-restriction profile recheck |
| `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/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/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 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
- `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
- `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`
- Handler modules stay decoupled from plugin internals — changes to `bot/handlers/X.py` flow through transparently
- **Callback data format**: All admin action callbacks encode `group_id` for per-group authorization: `action:{group_id}:{user_id}[:missing_code]`. The captcha callback uses `captcha_verify_{group_id}_{user_id}`. Callback handlers verify the caller is an admin of the specific group via `is_user_admin_in_group()`
- **Trust does not unrestrict**: Adding a user to the trusted list only clears probation — it does NOT lift restrictions. Use the separate "Buka pembatasan bot" (unrestrict) action to lift bot-applied restrictions. This prevents trust from inadvertently lifting manual admin restrictions
- **Admin cache excludes bots**: `fetch_group_admin_ids` passes `return_bots=False` to the Telegram API and filters `is_bot` client-side, so automated admin-bots are not treated as human admins

### Handler Priority Groups
```python
Expand All @@ -154,7 +162,7 @@ group=6 # JobQueue only (not a handler group): auto_restrict_job, refresh_admi
- 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
- Telegram `unrestrict_user` runs **before** DB finalization (remove_pending_captcha + start_new_user_probation) — if unrestrict fails, the pending captcha stays in DB and the user can retry by pressing the button again

### 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
Expand Down Expand Up @@ -282,7 +290,7 @@ if user.id not in admin_ids:

## Notes

- Registration order for all 23 built-in plugins lives in `MANIFEST_ORDER` (`plugins/definitions.py`), not scattered across `main.py`
- Registration order for all 25 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 @@ -296,7 +304,7 @@ if user.id not in admin_ids:
- DM handler scans all groups in registry for user membership and unrestriction
- **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 does DB writes (remove_pending_captcha, start_new_user_probation) BEFORE the Telegram `unrestrict_user` call. Reversible side effects first, irreversible last
- **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

## Policy

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ dependencies = [
"logfire>=2.6.1",
"pydantic>=2.12.5",
"pydantic-settings>=2.14.2",
"python-telegram-bot[job-queue]>=22.5",
"python-telegram-bot[job-queue]>=22.8",
"sqlmodel>=0.0.28",
]

Expand Down
74 changes: 66 additions & 8 deletions src/bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ def format_hours_display(hours: int) -> str:
# Warning mode (default): No restrictions, just warnings
WARNING_MESSAGE_NO_RESTRICTION = (
"⚠️ Hai {user_mention}, mohon lengkapi {missing_text} kamu "
"untuk mematuhi aturan grup.\n"
"Kamu akan dibatasi setelah {threshold_display}.\n\n"
"untuk mematuhi aturan grup.\n\n"
"📖 [Baca aturan grup]({rules_link})"
)

Expand Down Expand Up @@ -117,10 +116,12 @@ def format_hours_display(hours: int) -> str:
)

CAPTCHA_PENDING_DM_MESSAGE = (
"⏳ Kamu memiliki verifikasi captcha yang tertunda.\n"
"⏳ Kamu memiliki verifikasi captcha yang tertunda di grup berikut:\n{group_list}\n\n"
"Silakan cek grup dan tekan tombol verifikasi."
)

CAPTCHA_PENDING_DM_GROUP_LINE = "• Grup {group_id}"

CAPTCHA_INCOMPLETE_PROFILE_MESSAGE = (
"❌ Lengkapi {missing_text} terlebih dahulu, lalu tekan tombol ini lagi."
)
Expand Down Expand Up @@ -167,6 +168,38 @@ def format_hours_display(hours: int) -> str:
"✅ {user_mention} telah diverifikasi oleh admin. Silakan berdiskusi kembali."
)

VERIFY_SUCCESS_MESSAGE = (
"✅ User dengan ID {user_id} telah diverifikasi:\n"
"• Ditambahkan ke whitelist foto profil\n"
"• Riwayat warning dihapus di grup {group_id}\n\n"
"User ini tidak akan dicek foto profil lagi."
)

VERIFY_SUCCESS_WITH_UNRESTRICT_MESSAGE = (
"✅ User dengan ID {user_id} telah diverifikasi:\n"
"• Ditambahkan ke whitelist foto profil\n"
"• Pembatasan bot dicabut di grup {group_id}\n"
"• Riwayat warning dihapus\n\n"
"User ini tidak akan dicek foto profil lagi."
)

UNVERIFY_SUCCESS_MESSAGE = (
"✅ User dengan ID {target_user_id} telah dihapus dari whitelist verifikasi foto."
)

UNRESTRICT_SUCCESS_MESSAGE = (
"✅ Pembatasan bot untuk user `{user_id}` telah dicabut di grup {group_id}."
)

UNRESTRICT_FAILED_MESSAGE = (
"❌ Gagal membuka pembatasan untuk user `{user_id}` di grup {group_id}. "
"Pastikan bot memiliki izin yang cukup."
)

UNRESTRICT_NOT_NEEDED_MESSAGE = (
"ℹ️ User `{user_id}` tidak dibatasi oleh bot di grup {group_id}."
)

ADMIN_CHECK_PROMPT = (
"📋 User: {user_mention} (ID: `{user_id}`)\n\n"
"Status Profil:\n"
Expand All @@ -179,6 +212,18 @@ def format_hours_display(hours: int) -> str:

ADMIN_CHECK_ACTION_INCOMPLETE = "⚠️ Profil tidak lengkap. Pilih aksi:"

ADMIN_CHECK_GROUP_PROMPT = (
"📋 User: {user_mention} (ID: `{user_id}`)\n\n"
"Status Profil:\n"
"• Foto Profil: {photo_status}\n"
"• Username: {username_status}\n\n"
"Pilih grup untuk melakukan aksi:"
)

ADMIN_CHECK_GROUP_NONE = (
"❌ Kamu bukan admin di grup mana pun yang dipantau oleh bot ini."
)

ADMIN_WARN_USER_MESSAGE = (
"⚠️ Hai {user_mention}, mohon lengkapi {missing_text} kamu "
"untuk mematuhi aturan grup.\n\n"
Expand All @@ -194,9 +239,10 @@ def format_hours_display(hours: int) -> str:
TRUST_USER_ID_INVALID_MESSAGE = "❌ User ID harus berupa angka."

TRUST_ADDED_MESSAGE = (
"✅ User `{user_id}` ditambahkan ke trusted list.\n"
"• Probation dibersihkan di {probation_clear_count} grup\n"
"• Unrestrict dicoba di {unrestrict_count} grup"
"✅ User `{user_id}` ditambahkan ke trusted list (kecualikan dari anti-spam).\n"
"• Probation dibersihkan di {probation_clear_count} grup\n\n"
"Catatan: Trust tidak membuka pembatasan. Gunakan tombol \"Buka pembatasan bot\" "
"untuk mencabut pembatasan yang diterapkan oleh bot ini."
)

TRUST_ALREADY_EXISTS_MESSAGE = "ℹ️ User `{user_id}` sudah ada di trusted list."
Expand All @@ -219,9 +265,21 @@ def format_hours_display(hours: int) -> str:

TRUST_CALLBACK_INVALID_MESSAGE = "❌ Data callback tidak valid."

CHECK_TRUST_BUTTON_LABEL = "🤝 Trust User"
TRUST_NO_GROUP_PERMISSION_MESSAGE = (
"❌ Kamu bukan admin di grup ini."
)

CHECK_TRUST_BUTTON_LABEL = "🛡️ Kecualikan dari anti-spam"

CHECK_UNTRUST_BUTTON_LABEL = "🛡️ Cabut pengecualian anti-spam"

CHECK_VERIFY_BUTTON_LABEL = "📷 Izinkan foto tersembunyi"

CHECK_UNVERIFY_BUTTON_LABEL = "❌ Cabut izin foto"

CHECK_UNRESTRICT_BUTTON_LABEL = "🔓 Buka pembatasan bot"

CHECK_UNTRUST_BUTTON_LABEL = "🤝 Untrust User"
CHECK_WARN_BUTTON_LABEL = "⚠️ Beri peringatan"

# Anti-spam probation warning for new users
NEW_USER_SPAM_WARNING = (
Expand Down
23 changes: 23 additions & 0 deletions src/bot/database/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,29 @@ def delete_user_warnings(self, user_id: int, group_id: int) -> int:
)
return count

def get_active_user_warning(self, user_id: int, group_id: int) -> UserWarning | None:
"""
Get an existing active (non-restricted) warning record without creating one.

Unlike ``get_or_create_user_warning``, this does NOT create a record
if none exists. Used to check for stale warnings when a user's profile
becomes compliant.

Args:
user_id: Telegram user ID.
group_id: Telegram group ID.

Returns:
UserWarning | None: Active warning record, or None if none exists.
"""
with Session(self._engine) as session:
statement = select(UserWarning).where(
UserWarning.user_id == user_id,
UserWarning.group_id == group_id,
~UserWarning.is_restricted,
)
return session.exec(statement).first()

def add_photo_verification_whitelist(
self, user_id: int, verified_by_admin_id: int, notes: str | None = None
) -> PhotoVerificationWhitelist:
Expand Down
52 changes: 32 additions & 20 deletions src/bot/handlers/captcha.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,11 +284,26 @@ async def captcha_callback_handler(

group_config = registry.get(group_id)

if group_config is None or not db.get_pending_captcha(target_user_id, group_id):
pending = db.get_pending_captcha(target_user_id, group_id)
if group_config is None or not pending:
logger.warning(f"No pending captcha found for user {target_user_id} in group {group_id}")
await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True)
return

# Validate that this callback comes from the original challenge message.
# Prevents stale or spoofed callbacks from acting on a different challenge.
if query.message and (
query.message.chat_id != pending.chat_id
or query.message.message_id != pending.message_id
):
logger.warning(
f"Captcha callback from wrong message for user {target_user_id}: "
f"expected chat={pending.chat_id} msg={pending.message_id}, "
f"got chat={query.message.chat_id} msg={query.message.message_id}"
)
await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True)
return

try:
result = await check_user_profile(context.bot, query.from_user)
except Exception:
Expand All @@ -304,34 +319,31 @@ async def captcha_callback_handler(
)
return

# DB finalization first (reversible). If it fails, user stays restricted
# in Telegram, DB row preserved, timeout still armed — user can retry.
# Telegram unrestrict first. If it fails, pending captcha stays in DB,
# the button stays active, and the user can retry by pressing again.
# The timeout job is still armed as a safety net.
try:
await unrestrict_user(context.bot, group_config.group_id, target_user_id)
logger.info(f"Unrestricted verified user {target_user_id}")
except Exception as e:
logger.error(f"Failed to unrestrict user {target_user_id}: {e}")
await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True)
return

# DB finalization after Telegram success. Idempotent guard:
# remove_pending_captcha returns False if a concurrent callback already
# cleaned up — ack quietly and stop.
try:
removed = db.remove_pending_captcha(target_user_id, group_config.group_id)
if not removed:
# Duplicate callback delivery (e.g. double-tap) raced us here —
# the other delivery already finalized this user. Ack and stop.
logger.info(f"Captcha for user {target_user_id} already finalized, ignoring duplicate callback")
await query.answer()
return
db.start_new_user_probation(target_user_id, group_config.group_id)
except Exception:
logger.error(f"DB finalization failed for user {target_user_id}", exc_info=True)
await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True)
return

# Telegram unrestrict second (irreversible). If it fails after DB cleanup,
# user is still restricted on Telegram, DB row is gone so
# handle_captcha_expiration is a no-op, and the verify button is gone.
# User waits for admin action. Acceptable: we never reported success on a
# state we couldn't fully transition.
try:
await unrestrict_user(context.bot, group_config.group_id, target_user_id)
logger.info(f"Unrestricted verified user {target_user_id}")
except Exception as e:
logger.error(f"Failed to unrestrict user {target_user_id}: {e}")
await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True)
return
# User is already unrestricted on Telegram. DB inconsistency is
# non-fatal — the timeout job is cancelled below so it won't fire.

job_name = get_captcha_job_name(group_config.group_id, target_user_id)
for job in context.job_queue.get_jobs_by_name(job_name):
Expand Down
Loading
Loading