You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Indonesian Telegram bot for multi-group profile enforcement (photo + username), captcha verification, and anti-spam protection. Built with python-telegram-bot v20+, SQLModel, Pydantic, and Logfire.
Commands
# Install dependencies (including dev)
uv sync --dev
# Run tests (100% coverage maintained)
uv run pytest
# Run single test file
uv run pytest tests/test_check.py
# Run single test function
uv run pytest tests/test_check.py::TestHandleCheckCommand::test_check_command_non_admin
# Run only property-based tests
uv run pytest tests/test_properties.py -v
# Run with coverage
uv run pytest --cov=bot --cov-report=term-missing
# Run linter
uv run ruff check .# Run type checker
uv run mypy src/bot/ tests/
# Run the bot
uv run pythonid-bot
# Run staging
BOT_ENV=staging uv run pythonid-bot
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
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
Handler Priority Groups
# Registration order comes from MANIFEST_ORDER (plugins/definitions.py), not main.py directlygroup=-1# topic_guard: Runs FIRSTgroup=0# commands, verify/unverify/check/trust callbacks, captcha, dm (14 plugins, order-independent)group=1# inline_keyboard_spam: Catches inline keyboard URL spamgroup=2# contact_spam: Blocks contact card sharinggroup=3# new_user_spam: Probation enforcement (links/forwards)group=4# duplicate_spam + bio_bait_spam: Repeated messages / bio-bait detectiongroup=5# profile_monitor: Runs LAST, profile compliance checkgroup=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 beforeunrestrict_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
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)
This prevents downstream spam/profile handlers from processing warning-topic traffic
Fail-closed: On get_chat_member API error, deletes the message (scoped to confirmed warning-topic only)
Early returns (no message, wrong group, wrong topic) happen OUTSIDE the try/except block
Singletons
get_settings() — Pydantic settings, @lru_cache
get_database() — DatabaseService, lazy init
BotInfoCache — Class-level cache for bot username/ID
Admin Cache
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 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 (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
State Machine (Progressive Restriction)
1st violation → Warning with threshold info
2nd to (N-1) → Silent increment (no spam)
Nth violation → Restrict + notification
Time threshold → Auto-restrict via scheduler (parallel path)
Database Conventions
SQLite with WAL mode + synchronous=NORMAL for write concurrency under a single-process bot
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
Python 3.11+ with type hints
Imports: stdlib → third-party → local
Async/await: All handlers are async
PTB v20+: Use ContextTypes.DEFAULT_TYPE, not legacy Dispatcher
Logging: Use logfire via stdlib logging.getLogger(__name__)
Error handling: Catch specific exceptions (TimedOut), log, return gracefully
No inline comments unless code is complex
Docstrings: Module-level required; function docstrings for public APIs
Testing
Async mode: asyncio_mode = auto — do NOT use @pytest.mark.asyncio decorators
No conftest.py: Fixtures defined locally in each test file (intentional isolation)
Fixtures: mock_update, mock_context, mock_settings — copy from existing tests
Database tests: Use temp_db fixture with tempfile.TemporaryDirectory
Mocking: AsyncMock for Telegram API; no real network calls
Property-based tests: tests/test_properties.py uses Hypothesis for pure functions (format helpers, URL whitelist, _format_person). 200 examples per test by default. New pure functions SHOULD have property tests
Coverage: 99% maintained (~970 tests) — check before committing
Test delta tracking: When a fix changes a function signature, update existing tests AND add a new round-trip test that asserts the actual values, not substring matches
Anti-Patterns (THIS PROJECT)
Forbidden
Why
@pytest.mark.asyncio decorator
asyncio_mode = auto handles this
Manual conftest.py fixtures
Project uses local fixture pattern
Raw SQL in handlers
Use DatabaseService methods
Hardcoded Indonesian text
Use constants.py templates
print() statements
Use logging.getLogger(__name__)
Empty except: blocks
Catch specific exceptions, log with exc_info=True
Unique Conventions
Indonesian Localization
All user-facing messages in constants.py
Time formatting: format_threshold_display(minutes) → "3 jam" or "30 menit"
Duration formatting: format_hours_display(hours) → "7 hari" or "12 jam"
Admin Authorization
admin_ids=context.bot_data.get("admin_ids", [])
ifuser.idnotinadmin_ids:
return# or send "Admin only" message
URL Whitelisting (Anti-spam)
Suffix-based hostname matching in is_url_whitelisted()
Steps: ruff → mypy → pytest (lint job always; test job runs when Python files changed)
Mypy config: Pragmatic, in pyproject.toml [tool.mypy]. Disables noisy error codes from PTB / SQLModel / Pydantic v2 (arg-type, attr-defined, index, union-attr, misc, return-value, call-arg). New code should remain free of these errors
Docker: Multi-stage build with uv, non-root user, 512MB limit
Where to Look (Plugin System)
Task
Location
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 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)
JobQueue admin refresh job runs every 10 minutes (first run after 10 min delay)
Captcha uses both ChatMemberHandler (for "Hide Join" groups) and MessageHandler fallback
Multi-group: handlers use get_group_config_for_update() instead of settings.group_id
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
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
Policy
Never mention AI usage, code generation tools, or automated assistance in commit messages, PR descriptions, code comments, or documentation