A comprehensive Telegram bot for managing group members with profile verification, captcha challenges, and anti-spam protection.
- Monitors all messages in one or more configured groups
- Multi-group support: Manage multiple groups from a single bot instance with isolated per-group settings via
groups.json - Checks if users have a public profile picture
- Checks if users have a username set
- Sends warnings to a dedicated topic (thread) for non-compliant users
- Warning topic protection: Only admins and the bot can post in the warning topic (messages + edited messages)
- Progressive restriction: Optional mode to restrict users after multiple warnings (message-based)
- Time-based auto-restriction: Automatically restricts users after X hours from first warning
- Scheduled job: Background scheduler checks and enforces time-based restrictions every 5 minutes
- DM unrestriction flow: Restricted users can DM the bot to get unrestricted after completing their profile
- Captcha verification: New members must verify they're human before joining (optional)
- Captcha timeout recovery: Automatically recovers pending verifications after bot restart
- New user probation: New members restricted from sending links/forwarded messages for 3 days (configurable)
- Contact card blocking: Prevents all non-admin members from sharing contact cards/phone numbers (delete + restrict)
- Duplicate message detection: Flags repeated near-identical messages within a configurable window
- Bio-bait detection: Catches obfuscated "check my bio" bait phrases and suspicious promo links in a sender's Telegram profile bio (monitor-only mode available)
- Anti-spam enforcement: Tracks violations and restricts spammers after threshold
- Trusted users: Admin-managed trusted list to bypass anti-spam + duplicate-spam checks
- /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
- /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)
- Automatic clearance: Sends notification when verified users' warnings are cleared
- Python 3.11+
- uv package manager
- Open Telegram and search for @BotFather
- Send
/newbotand follow the prompts - Copy the bot token you receive
- Create a new group or use an existing one
- Enable Topics in the group:
- Go to Group Settings → Topics → Enable Topics
- Create a topic for bot warnings (e.g., "Bot Warnings" or "Profile Alerts")
- Add your bot to the group as an Administrator with these permissions:
- Read messages
- Send messages
- Delete messages (for warning topic protection)
- Restrict members (for progressive restriction mode)
Option A: Using @userinfobot
- Add @userinfobot to your group
- The bot will reply with the group ID (negative number starting with
-100) - Remove the bot after getting the ID
Option B: Using your bot
- Temporarily add this handler to your bot to print chat IDs:
async def debug_handler(update, context): print(f"Chat ID: {update.effective_chat.id}")
- Send a message in the group and check the console
Option A: From message link
- Right-click any message in your warning topic
- Click "Copy Message Link"
- The link format is:
https://t.me/c/XXXXXXXXXX/TOPIC_ID/MESSAGE_ID - The
TOPIC_IDis the number you need (e.g.,123)
Option B: From forwarded message
- Forward a message from the topic to @userinfobot
- Look for the
message_thread_idin the response
Note: The "General" topic has ID 1. Custom topics have higher IDs.
cp .env.example .envEdit .env with your values:
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
GROUP_ID=-1001234567890
WARNING_TOPIC_ID=42
RESTRICT_FAILED_USERS=false
WARNING_THRESHOLD=3
WARNING_TIME_THRESHOLD_MINUTES=180
RULES_LINK=https://t.me/yourgroup/rulesTo manage multiple groups from a single bot instance, use a groups.json configuration file:
cp groups.json.example groups.jsonAdd GROUPS_CONFIG_PATH=groups.json to your .env file, then edit groups.json:
[
{
"group_id": -1001234567890,
"warning_topic_id": 123,
"restrict_failed_users": false,
"warning_threshold": 3,
"warning_time_threshold_minutes": 180,
"captcha_enabled": false,
"captcha_timeout_seconds": 120,
"new_user_probation_hours": 72,
"new_user_violation_threshold": 3,
"rules_link": "https://t.me/pythonID/290029/321799"
},
{
"group_id": -1009876543210,
"warning_topic_id": 456,
"restrict_failed_users": true,
"warning_threshold": 5,
"warning_time_threshold_minutes": 60,
"captcha_enabled": true,
"captcha_timeout_seconds": 180,
"new_user_probation_hours": 168,
"new_user_violation_threshold": 2,
"rules_link": "https://t.me/mygroup/rules"
}
]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.
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.
# Install dependencies (including dev tools: ruff, mypy, hypothesis, pytest)
uv sync --dev
# Run the bot (production)
uv run pythonid-bot
# Run the bot (staging)
BOT_ENV=staging uv run pythonid-bot
# Stop gracefully with Ctrl+C
# The bot will properly shut down the JobQueue scheduler before exitingThe bot supports multiple environments via the BOT_ENV variable:
| BOT_ENV | Config File |
|---|---|
production (default) |
.env |
staging |
.env.staging |
# Production (default)
uv run pythonid-bot
# Staging
BOT_ENV=staging uv run pythonid-bot# Run tests
uv run pytest
# Run tests with coverage
uv run pytest --cov=bot --cov-report=term-missing
# Run tests verbosely
uv run pytest -v
# Run only property-based tests
uv run pytest tests/test_properties.py -v
# Type check
uv run mypy src/bot/ tests/The project maintains comprehensive test coverage:
- Coverage: 98%+ (~2,500 statements, <2% unreachable)
- Tests: 977+ total (includes 19 Hypothesis property tests)
- Pass Rate: 100%
- Property tests:
tests/test_properties.pyexercises pure functions (format helpers, URL whitelist, name formatters) with random inputs and shrinks failing cases to minimal examples - Mypy: Pragmatic config in
pyproject.toml. Disables error codes that are noisy from PTB / SQLModel / Pydantic v2; catches real type bugs in new code
All modules are fully unit tested with:
- Mocked async dependencies (telegram bot API calls)
- Edge case handling (errors, empty results, boundary conditions)
- Database initialization and schema validation
- Background job testing (JobQueue integration, job configuration, auto-restriction logic)
- Captcha verification flow (new member handling, callback verification, timeout handling, profile photo + username check)
- Anti-spam protection (contact cards, inline keyboards, forwarded messages, URL whitelisting, external replies)
- Plugin registration (built-in plugins wired through
PluginManager)
PythonID/
├── pyproject.toml
├── .env # Your configuration (not committed)
├── .env.example # Example configuration
├── README.md
├── AGENTS.md # Developer guidance for the codebase
├── data/
│ └── bot.db # SQLite database (auto-created)
├── scripts/
│ └── backfill_trusted_names.py # One-shot backfill for /trusted admin names
├── tests/
│ ├── test_anti_spam.py
│ ├── test_backfill_trusted_names.py
│ ├── test_bot_info.py
│ ├── test_captcha.py
│ ├── test_captcha_recovery.py
│ ├── test_check.py
│ ├── test_config.py
│ ├── test_constants.py
│ ├── test_database.py
│ ├── test_dm_handler.py
│ ├── test_duplicate_spam.py
│ ├── test_group_config.py
│ ├── test_main_plugins_bootstrap.py
│ ├── test_message_handler.py
│ ├── test_photo_verification.py
│ ├── test_plugin_captcha.py
│ ├── test_plugin_config.py
│ ├── test_plugin_definitions.py
│ ├── test_plugin_manager.py
│ ├── test_properties.py # Hypothesis property-based tests
│ ├── test_scheduler.py
│ ├── test_telegram_utils.py
│ ├── test_topic_guard.py
│ ├── test_trust_handler.py
│ ├── test_user_checker.py
│ ├── test_verify_handler.py
│ └── test_whitelist.py
└── src/
└── bot/
├── main.py # Entry point with JobQueue integration + PluginManager bootstrap
├── config.py # Pydantic settings
├── constants.py # Shared constants
├── group_config.py # Multi-group configuration (GroupConfig, GroupRegistry)
├── plugins/ # Modular plugin system
│ ├── manager.py # PluginManager — discovers + registers built-ins
│ ├── definitions.py # Plugin class contract
│ ├── config.py # guard_plugin("name") per-group runtime gate
│ └── builtin/ # One plugin per handler domain
│ ├── captcha.py
│ ├── profile_monitor.py
│ ├── spam.py
│ ├── topic_guard.py
│ ├── commands.py
│ ├── dm.py
│ └── jobs.py
├── handlers/ # Underlying handler implementations (wrapped by plugins)
│ ├── anti_spam.py # Anti-spam (contact cards, inline keyboards, probation)
│ ├── captcha.py # Captcha + profile photo/username check
│ ├── dm.py # DM unrestriction handler
│ ├── message.py # Group message handler
│ ├── topic_guard.py # Warning topic protection
│ ├── trust.py # /trust, /untrust, /trusted admin commands
│ ├── verify.py # /verify and /unverify command handlers
│ ├── duplicate_spam.py # Duplicate message detection
│ └── bio_bait.py # Bio-bait spam (bait phrases + suspicious profile bio links)
├── database/
│ ├── models.py # SQLModel schemas (5 tables)
│ └── service.py # Database operations
└── services/
├── admin_cache.py # Admin ID cache + refresh
├── bot_info.py # Bot info caching
├── captcha_recovery.py # Captcha timeout recovery
├── scheduler.py # JobQueue background job
├── telegram_utils.py # Shared telegram utilities
└── user_checker.py # Profile validation
The following diagram illustrates the complete bot workflow including captcha verification, anti-spam protection, profile monitoring, restriction logic, DM unrestriction, admin verification, and background scheduler jobs:
flowchart TD
Start([Bot Starts]) --> InitDB[Init Database + Group Registry<br/>from groups.json or .env]
InitDB --> RegisterHandlers[PluginManager.register_all:<br/>register handlers + jobs<br/>in MANIFEST_ORDER]
RegisterHandlers --> ComputeMap[Compute Per-Group<br/>Effective Plugin Map]
ComputeMap --> RunPolling[run_polling starts]
RunPolling --> FetchAdmins[post_init: Fetch<br/>Group Admin IDs]
FetchAdmins --> LoadTrusted[(Load Trusted User IDs<br/>into bot_data cache)]
LoadTrusted --> RecoverCaptcha{Any Group Has<br/>Captcha Enabled?}
RecoverCaptcha -->|Yes| RecoverPending[Recover Pending Captchas]
RecoverCaptcha -->|No| Poll[Poll for Updates]
RecoverPending --> Poll
Poll --> UpdateType{Update Type?}
%% ===================== New Member Flow (captcha.py, group=0) =====================
UpdateType -->|New Member Joins| EntryPoint{Entry Point}
EntryPoint -->|ChatMemberHandler<br/>works with Hide Join| MaybeStart[_maybe_start_captcha]
EntryPoint -->|MessageHandler<br/>NEW_CHAT_MEMBERS fallback| MaybeStart
MaybeStart --> StartProbationInit[(Start New User Probation<br/>unconditional)]
StartProbationInit --> CheckCaptchaEnabled{Captcha Enabled<br/>for this Group?}
CheckCaptchaEnabled -->|No| EndNM1([Done - probation only,<br/>not restricted])
CheckCaptchaEnabled -->|Yes| RestrictAndChallenge[Restrict Member]
RestrictAndChallenge --> SendChallenge[Send Verify Button<br/>captcha_verify_group_user]
SendChallenge --> StorePending[(Store Pending<br/>Captcha Validation)]
StorePending --> ScheduleTimeout[Schedule Timeout Job]
ScheduleTimeout --> WaitCaptcha[Wait for Callback<br/>or Timeout]
WaitCaptcha --> CaptchaAnswer{Which Fires<br/>First?}
CaptchaAnswer -->|Wrong user tapped button| ShowError[Alert: Not Your Button]
ShowError --> WaitCaptcha
CaptchaAnswer -->|Timeout job fires| CaptchaTimeout[captcha_timeout_callback]
CaptchaTimeout --> HandleExpiration[handle_captcha_expiration<br/>captcha_recovery.py]
HandleExpiration --> EndNM2([User stays restricted<br/>pending row cleared])
CaptchaAnswer -->|Correct user tapped| CheckProfileCaptcha[check_user_profile:<br/>photo + username]
CheckProfileCaptcha --> ProfileOkCaptcha{Profile<br/>Complete?}
ProfileOkCaptcha -->|No| ShowIncomplete[Alert: Missing Items<br/>pending row + timeout kept armed]
ShowIncomplete --> WaitCaptcha
ProfileOkCaptcha -->|Yes| RemovePending[(Remove Pending Captcha)]
RemovePending --> RestartProbation[(Restart Probation Clock)]
RestartProbation --> UnrestrictMember[Unrestrict Member<br/>irreversible call, done last]
UnrestrictMember --> CancelTimeout[Cancel Timeout Job]
CancelTimeout --> UpdateMessage[Edit Message: Verified]
UpdateMessage --> EndNM3([Done])
%% ===================== Group Message Pipeline (real PTB group order) =====================
UpdateType -->|Group Message| G_TopicGuard{group=-1 topic_guard:<br/>In Warning Topic?}
G_TopicGuard -->|No, or lookup error| G_InlineGate
G_TopicGuard -->|Yes, incl. API error<br/>fail-closed| G_TopicIsBotAdmin{Sender is<br/>Bot or Admin?}
G_TopicIsBotAdmin -->|Yes| StopTopic1([ApplicationHandlerStop<br/>message allowed])
G_TopicIsBotAdmin -->|No| G_TopicDelete[Delete Message]
G_TopicDelete --> StopTopic2([ApplicationHandlerStop])
G_InlineGate{group=1 inline_keyboard_spam:<br/>Bot or Admin/Trusted?}
G_InlineGate -->|Yes| G_ContactGate
G_InlineGate -->|No| G_InlineCheck{Inline Button URL<br/>Not Whitelisted?}
G_InlineCheck -->|No match| G_ContactGate
G_InlineCheck -->|Yes| G_InlineDelete[Delete Message]
G_InlineDelete --> G_InlineRestrict[Restrict User<br/>always, no config gate]
G_InlineRestrict --> G_InlineNotify[Notify Warning Topic]
G_InlineNotify --> StopInline([ApplicationHandlerStop])
G_ContactGate{group=2 contact_spam:<br/>Bot or Admin/Trusted?}
G_ContactGate -->|Yes| G_NewUserGate
G_ContactGate -->|No| G_ContactCheck{Message Has<br/>Contact Card?}
G_ContactCheck -->|No| G_NewUserGate
G_ContactCheck -->|Yes| G_ContactDelete[Delete Message]
G_ContactDelete --> G_ContactRestrictGate{contact_spam_restrict<br/>Enabled for Group?}
G_ContactRestrictGate -->|Yes| G_ContactRestrict[Restrict User]
G_ContactRestrict --> G_ContactNotify[Notify Warning Topic]
G_ContactRestrictGate -->|No, delete only| G_ContactNotify
G_ContactNotify --> StopContact([ApplicationHandlerStop])
G_NewUserGate{group=3 new_user_spam:<br/>Bot or Admin/Trusted?}
G_NewUserGate -->|Yes| G_Group4Note
G_NewUserGate -->|No| G_OnProbation{User On<br/>Probation?}
G_OnProbation -->|No| G_Group4Note
G_OnProbation -->|Yes| G_ProbationExpired{Probation Window<br/>Elapsed?}
G_ProbationExpired -->|Yes| G_ClearProbation[(Clear Probation Record)]
G_ClearProbation --> G_Group4Note
G_ProbationExpired -->|No| G_Violation{Forwarded, non-whitelisted<br/>link, external reply,<br/>story, or media?}
G_Violation -->|No| G_Group4Note
G_Violation -->|Yes| G_ViolationDelete[Delete Message]
G_ViolationDelete --> G_ViolationIncrement[(Increment Violation Count)]
G_ViolationIncrement --> G_ViolationCount{Violation<br/>Count?}
G_ViolationCount -->|1st| G_ViolationWarn[Send Probation Warning]
G_ViolationWarn --> StopNewUser1([ApplicationHandlerStop])
G_ViolationCount -->|2 to N-1| StopNewUser2([ApplicationHandlerStop<br/>silent, delete only])
G_ViolationCount -->|">= new_user_violation_threshold"| G_ViolationRestrict[Restrict User]
G_ViolationRestrict --> G_ViolationNotify[Send Restriction Notice]
G_ViolationNotify --> StopNewUser3([ApplicationHandlerStop])
G_Group4Note[/"group=4: two handlers share the SAME filter<br/>(GROUPS & not COMMAND). duplicate_spam registers<br/>first in MANIFEST_ORDER and PTB block defaults to<br/>True, so it always claims the update -<br/>bio_bait_spam's handler never runs in practice."/]
G_Group4Note --> G_DupGate{duplicate_spam:<br/>Enabled, Bot,<br/>or Admin/Trusted?}
G_DupGate -->|Disabled, bot,<br/>or admin/trusted| G_ProfileGate
G_DupGate -->|No| G_DupLength{Message Long Enough?<br/>duplicate_spam_min_length}
G_DupLength -->|No| G_ProfileGate
G_DupLength -->|Yes| G_DupSimilar{Similar Messages in Window<br/>>= threshold-1?<br/>SequenceMatcher.ratio >= similarity}
G_DupSimilar -->|No| G_ProfileGate
G_DupSimilar -->|Yes| G_DupDelete[Delete Matching Messages<br/>in Window]
G_DupDelete --> G_DupRestrict[Restrict User]
G_DupRestrict --> G_DupNotify[Notify Warning Topic]
G_DupNotify --> StopDup([ApplicationHandlerStop])
G_ProfileGate{group=5 profile_monitor:<br/>Sender is Bot?<br/>no admin bypass here}
G_ProfileGate -->|Yes| EndG0([Ignore])
G_ProfileGate -->|No| G_Whitelist{User in Photo<br/>Verification Whitelist?}
G_Whitelist -->|Yes| EndG1([Allow])
G_Whitelist -->|No| G_CheckProfile[check_user_profile:<br/>photo + username]
G_CheckProfile --> G_ProfileComplete{Profile<br/>Complete?}
G_ProfileComplete -->|Yes| EndG2([Allow])
G_ProfileComplete -->|No| G_Mode{restrict_failed_users<br/>for this Group?}
G_Mode -->|False: Warning Mode| G_WarnOnly[Send Warning to Topic<br/>time threshold mentioned, no tracking]
G_WarnOnly --> EndG3([Done])
G_Mode -->|True: Progressive Mode| G_MsgCount{Message Count<br/>for this User?}
G_MsgCount -->|1st message| G_FirstWarn[Send Warning with<br/>Message + Time Thresholds]
G_FirstWarn --> G_StoreWarn[(Store Warning in DB<br/>with timestamp)]
G_StoreWarn --> EndG4([Done])
G_MsgCount -->|"2 to N-1"| G_SilentInc[(Silent: Increment Count)]
G_SilentInc --> EndG5([Done])
G_MsgCount -->|">= warning_threshold"| G_RestrictUser[Apply Restriction<br/>Mute Permissions]
G_RestrictUser --> G_MarkRestricted[(Mark Restricted in DB)]
G_MarkRestricted --> G_RestrictMsg[Send Restriction Notice<br/>with DM Link]
G_RestrictMsg --> EndG6([Done])
%% ===================== DM Flow (dm.py, group=0, PRIVATE & TEXT) =====================
UpdateType -->|Private Message| DM_FindGroups[Scan Every Monitored Group<br/>for This User's Membership]
DM_FindGroups --> DM_InGroup{Member of<br/>Any Group?}
DM_InGroup -->|No| DM_NotInGroup[Send: Not in Group]
DM_InGroup -->|Yes| DM_PendingCaptcha{Pending Captcha in<br/>Any Member Group?}
DM_PendingCaptcha -->|Yes| DM_CaptchaRedirect[Send: Complete Captcha<br/>in Group First]
DM_PendingCaptcha -->|No| DM_CheckProfile[check_user_profile]
DM_CheckProfile --> DM_ProfileOk{Profile<br/>Complete?}
DM_ProfileOk -->|No| DM_Missing[Send: Missing Items]
DM_ProfileOk -->|Yes| DM_FindRestricted[Find Groups Where<br/>Bot Restricted This User]
DM_FindRestricted --> DM_AnyRestricted{Any Bot-Restricted<br/>Group Found?}
DM_AnyRestricted -->|No| DM_NoRestriction[Send: No Bot Restriction<br/>e.g. admin-restricted instead]
DM_AnyRestricted -->|Yes| DM_StatusCheck{Per Group: Still<br/>Restricted on Telegram?}
DM_StatusCheck -->|No| DM_ClearOnly[(Clear DB Record Only)]
DM_StatusCheck -->|Yes| DM_Unrestrict[unrestrict_user API call]
DM_Unrestrict --> DM_ClearAndNotify[(Clear DB Record<br/>+ Notify Warning Topic)]
DM_ClearOnly --> DM_Result{Any Group<br/>Actually Unrestricted?}
DM_ClearAndNotify --> DM_Result
DM_Result -->|Yes, at least one| DM_Success[Send: Success Message]
DM_Result -->|No, all already clear| DM_AlreadyDone[Send: Already Unrestricted]
%% ===================== Scheduler Jobs (JobQueue, group=6, background) =====================
ComputeMap -.->|Every 5 min| SchedulerJob[auto_restrict_job]
SchedulerJob --> QueryDB[(Query Warnings Past<br/>Time Threshold, All Groups)]
QueryDB --> HasExpired{Expired<br/>Warnings?}
HasExpired -->|No| EndJob1([Wait Next Cycle])
HasExpired -->|Yes| CheckKicked{User<br/>Kicked/Left?}
CheckKicked -->|Yes| ClearKicked[(Clear Record)]
CheckKicked -->|No| ApplyTimeRestriction[Apply Restriction<br/>Mute Permissions]
ApplyTimeRestriction --> MarkTimeRestricted[(Mark as Restricted)]
MarkTimeRestricted --> SendTimeNotice[Send Time-Based<br/>Restriction Notice]
ClearKicked --> EndJob1
SendTimeNotice --> EndJob1
ComputeMap -.->|Every 10 min| AdminRefreshJob[refresh_admin_ids_job]
AdminRefreshJob --> AdminRefreshLoop{Per Group:<br/>API Call Succeeded?}
AdminRefreshLoop -->|Yes| AdminRefreshUpdate[(Update Cached<br/>Admin IDs)]
AdminRefreshLoop -->|No| AdminRefreshKeep[(Keep Existing Cache<br/>fail-soft, never empties)]
%% ===================== Admin Commands: verify / unverify / check (group=0, DM-only) =====================
UpdateType -->|"/verify user_id"| Cmd_VerifyAdmin{Admin +<br/>DM?}
Cmd_VerifyAdmin -->|No| AdminDeny[Reply: Admin Only]
Cmd_VerifyAdmin -->|Yes| Do_Verify[verify_user]
UpdateType -->|"/unverify user_id"| Cmd_UnverifyAdmin{Admin +<br/>DM?}
Cmd_UnverifyAdmin -->|No| AdminDeny
Cmd_UnverifyAdmin -->|Yes| Do_Unverify[unverify_user]
UpdateType -->|"/check user_id"| Cmd_CheckAdmin{Admin +<br/>DM?}
Cmd_CheckAdmin -->|No| AdminDeny
Cmd_CheckAdmin -->|Yes| Do_CheckProfile[check_user_profile<br/>_build_check_response]
UpdateType -->|Forwarded Message<br/>in DM| Cmd_ForwardAdmin{Admin +<br/>DM?}
Cmd_ForwardAdmin -->|No| AdminDeny
Cmd_ForwardAdmin -->|Yes| Cmd_ExtractUser{Extract User<br/>Info from Forward?}
Cmd_ExtractUser -->|Failed| Cmd_ExtractError[Send: Cannot Extract User]
Cmd_ExtractUser -->|Success| Do_CheckProfile
Do_CheckProfile --> Cmd_ProfileState{Profile<br/>Complete?}
Cmd_ProfileState -->|Complete| Cmd_ButtonsComplete[Show Unverify<br/>+ Trust/Untrust Buttons]
Cmd_ProfileState -->|Incomplete| Cmd_ButtonsIncomplete[Show Warn + Verify<br/>+ Trust/Untrust Buttons]
%% Callback: Verify / Unverify (also reachable from the buttons above)
UpdateType -->|"Verify Button<br/>verify:user_id"| CB_VerifyAdmin{Admin?}
CB_VerifyAdmin -->|No| AdminDeny
CB_VerifyAdmin -->|Yes| Do_Verify
Do_Verify --> Verify_Whitelist[(Add to Photo<br/>Verification Whitelist)]
Verify_Whitelist --> Verify_PerGroup[Per Group: Unrestrict<br/>+ Delete Warning Records]
Verify_PerGroup --> Verify_HadWarnings{Had<br/>Warnings?}
Verify_HadWarnings -->|Yes| Verify_Clearance[Send Clearance<br/>to Warning Topic]
Verify_Clearance --> Verify_Success[Reply/Edit: User Verified]
Verify_HadWarnings -->|No| Verify_Success
UpdateType -->|"Unverify Button<br/>unverify:user_id"| CB_UnverifyAdmin{Admin?}
CB_UnverifyAdmin -->|No| AdminDeny
CB_UnverifyAdmin -->|Yes| Do_Unverify
Do_Unverify --> Unverify_Remove[(Remove from Whitelist)]
Unverify_Remove --> Unverify_Success[Reply/Edit: User Unverified]
%% Callback: Warn (from check.py's incomplete-profile button)
UpdateType -->|"Warn Button<br/>warn:user_id:code"| CB_WarnAdmin{Admin?}
CB_WarnAdmin -->|No| AdminDeny
CB_WarnAdmin -->|Yes| Warn_Build[Build Warning Text<br/>from Missing Photo/Username Code]
Warn_Build --> Warn_Broadcast[Send to Every Monitored<br/>Group's Warning Topic]
Warn_Broadcast --> Warn_Success[Edit Message: Sent]
%% ===================== Admin Commands: trust / untrust / trusted (group=0, DM-only) =====================
UpdateType -->|"/trust user_id or forward"| Cmd_TrustAdmin{Admin +<br/>DM?}
Cmd_TrustAdmin -->|No| AdminDeny
Cmd_TrustAdmin -->|Yes| Do_Trust[trust_user]
UpdateType -->|"Trust Button<br/>trust:user_id"| CB_TrustAdmin{Admin?}
CB_TrustAdmin -->|No| AdminDeny
CB_TrustAdmin -->|Yes| Do_Trust
Do_Trust --> Trust_DB[(Add TrustedUser Row<br/>caches admin+user names<br/>+ Clear Probation<br/>+ Unrestrict, per group)]
Trust_DB --> Trust_Cache[(Update In-Memory<br/>Trusted ID Cache)]
Trust_Cache --> Trust_Success[Reply: Trusted]
UpdateType -->|"/untrust user_id"| Cmd_UntrustAdmin{Admin +<br/>DM?}
Cmd_UntrustAdmin -->|No| AdminDeny
Cmd_UntrustAdmin -->|Yes| Do_Untrust[untrust_user]
UpdateType -->|"Untrust Button<br/>untrust:user_id"| CB_UntrustAdmin{Admin?}
CB_UntrustAdmin -->|No| AdminDeny
CB_UntrustAdmin -->|Yes| Do_Untrust
Do_Untrust --> Untrust_DB[(Remove TrustedUser Row)]
Untrust_DB --> Untrust_Cache[(Update Cache)]
Untrust_Cache --> Untrust_Success[Reply: Untrusted<br/>or Not Found]
UpdateType -->|"/trusted"| Cmd_TrustedAdmin{Admin +<br/>DM?}
Cmd_TrustedAdmin -->|No| AdminDeny
Cmd_TrustedAdmin -->|Yes| Trusted_Read[(Read All Trusted Users)]
Trusted_Read --> Trusted_List[Reply: Formatted List<br/>name, username, trusted-by, date]
classDef processNode fill:#1a1a2e,stroke:#16213e,color:#eee
classDef decisionNode fill:#0f3460,stroke:#16213e,color:#eee
classDef dataNode fill:#16213e,stroke:#0f3460,color:#eee
classDef actionNode fill:#533483,stroke:#16213e,color:#eee
classDef endNode fill:#e94560,stroke:#16213e,color:#eee
classDef startNode fill:#1a5f7a,stroke:#16213e,color:#eee
classDef noteNode fill:#5c3a00,stroke:#e9c46a,color:#ffe8b3
class InitDB,RegisterHandlers,ComputeMap,RunPolling,FetchAdmins,RecoverPending,Poll,MaybeStart,CheckProfileCaptcha,DM_CheckProfile,DM_FindGroups,DM_FindRestricted,RestrictAndChallenge,SendChallenge,ScheduleTimeout,WaitCaptcha,G_CheckProfile,Do_CheckProfile processNode
class UpdateType,EntryPoint,RecoverCaptcha,CheckCaptchaEnabled,CaptchaAnswer,ProfileOkCaptcha,G_TopicGuard,G_TopicIsBotAdmin,G_InlineGate,G_InlineCheck,G_ContactGate,G_ContactCheck,G_ContactRestrictGate,G_NewUserGate,G_OnProbation,G_ProbationExpired,G_Violation,G_ViolationCount,G_DupGate,G_DupLength,G_DupSimilar,G_ProfileGate,G_Whitelist,G_ProfileComplete,G_Mode,G_MsgCount,DM_InGroup,DM_PendingCaptcha,DM_ProfileOk,DM_AnyRestricted,DM_StatusCheck,DM_Result,HasExpired,CheckKicked,AdminRefreshLoop,Cmd_VerifyAdmin,Cmd_UnverifyAdmin,Cmd_CheckAdmin,Cmd_ForwardAdmin,Cmd_ExtractUser,Cmd_ProfileState,CB_VerifyAdmin,CB_UnverifyAdmin,Verify_HadWarnings,CB_WarnAdmin,Cmd_TrustAdmin,CB_TrustAdmin,Cmd_UntrustAdmin,CB_UntrustAdmin,Cmd_TrustedAdmin decisionNode
class StartProbationInit,StorePending,RemovePending,RestartProbation,G_ClearProbation,G_ViolationIncrement,G_StoreWarn,G_SilentInc,G_MarkRestricted,DM_ClearOnly,DM_ClearAndNotify,QueryDB,ClearKicked,MarkTimeRestricted,AdminRefreshUpdate,AdminRefreshKeep,Verify_Whitelist,Unverify_Remove,Trust_DB,Trust_Cache,Untrust_DB,Untrust_Cache,Trusted_Read,LoadTrusted dataNode
class ShowError,CaptchaTimeout,HandleExpiration,ShowIncomplete,UnrestrictMember,CancelTimeout,UpdateMessage,G_TopicDelete,G_InlineDelete,G_InlineRestrict,G_InlineNotify,G_ContactDelete,G_ContactRestrict,G_ContactNotify,G_ViolationDelete,G_ViolationWarn,G_ViolationRestrict,G_ViolationNotify,G_DupDelete,G_DupRestrict,G_DupNotify,G_WarnOnly,G_FirstWarn,G_RestrictUser,G_RestrictMsg,DM_NotInGroup,DM_CaptchaRedirect,DM_Missing,DM_NoRestriction,DM_Unrestrict,DM_Success,DM_AlreadyDone,SchedulerJob,ApplyTimeRestriction,SendTimeNotice,AdminRefreshJob,AdminDeny,Cmd_ExtractError,Cmd_ButtonsComplete,Cmd_ButtonsIncomplete,Do_Verify,Do_Unverify,Do_Trust,Do_Untrust,Verify_PerGroup,Verify_Clearance,Verify_Success,Unverify_Success,Warn_Build,Warn_Broadcast,Warn_Success,Trust_Success,Untrust_Success,Trusted_List actionNode
class EndNM1,EndNM2,EndNM3,StopTopic1,StopTopic2,StopInline,StopContact,StopNewUser1,StopNewUser2,StopNewUser3,StopDup,EndG0,EndG1,EndG2,EndG3,EndG4,EndG5,EndG6,EndJob1 endNode
class Start startNode
class G_Group4Note noteNode
The bot is organized into clear modules for maintainability:
- main.py: Entry point with python-telegram-bot's JobQueue integration, plugin manager bootstrap, admin cache refresh, and graceful shutdown
- plugins/: Modular plugin system.
PluginManagerdiscovers built-in plugins insrc/bot/plugins/builtin/, each wrapping a handler module with per-group runtime gating viaguard_plugin("name"). Add a new plugin by dropping a file inbuiltin/ - handlers/: Message processing logic (priority groups -1 through 4). Plugin wrappers transparently apply
guard_plugin, so changes to handler internals flow through without plugin updatestopic_guard.py: Protects warning topic (group=-1, messages + edited messages, fail-closed)message.py: Monitors group messages and sends warnings/restrictions (group=5)dm.py: Handles DM unrestriction flowcaptcha.py: Captcha verification for new members, including profile photo + username checkanti_spam.py: Inline keyboard spam (group=1) + contact card spam (group=2) + new user probation enforcement (group=3)duplicate_spam.py: Repeated message detection (group=4)bio_bait.py: Obfuscated bait-phrase + suspicious profile-bio link detection (group=4, monitor-only mode available)verify.py: /verify and /unverify command handlerscheck.py: /check command + forwarded message handlingtrust.py: /trust, /untrust, /trusted admin commands (TrustedUser table caches names at trust time so /trusted renders without API calls)
- services/: Business logic and utilities
scheduler.py: JobQueue background job that runs every 5 minutes for time-based auto-restrictionsuser_checker.py: Profile validation (photo + username check) — used by both the captcha gate and the per-message monitorbot_info.py: Caches bot metadata to avoid repeated API callstelegram_utils.py: Shared telegram utilities (user status checks, etc.)captcha_recovery.py: Captcha timeout recovery on bot restartadmin_cache.py: Admin ID cache + 10-minute refresh job
- database/: Data persistence
service.py: Database operations with SQLitemodels.py: Data models using SQLModel (UserWarning, PhotoVerificationWhitelist, PendingCaptchaValidation, NewUserProbation, TrustedUser)
- config.py: Environment configuration using Pydantic
- group_config.py: Multi-group configuration management (GroupConfig model, GroupRegistry for O(1) lookup, groups.json loading with .env fallback)
- constants.py: Centralized message templates and utilities for consistent formatting across handlers
- scripts/: Operator one-shots (
scripts/backfill_trusted_names.pyfor pre-existing /trusted rows)
New members are restricted immediately on join and presented with a captcha button. The verification flow now requires both the captcha click and a complete Telegram profile (public profile photo + username) before unrestriction:
- New member joins → bot restricts them + sends captcha message + schedules timeout job
- User clicks the captcha button within
CAPTCHA_TIMEOUT_SECONDS - Bot calls
check_user_profile()to verify photo + username - If profile is complete: remove pending captcha → start probation → unrestrict → cancel timeout
- If profile is incomplete: alert shows the missing items, captcha record preserved, timeout still armed — user can fix profile and click again
The DB finalization (remove pending + start probation) runs before the Telegram unrestrict_user call, so a DB write failure leaves the user still restricted with state intact instead of leaking an inconsistent state.
- Bot listens to all text messages in the configured group
- For each message, it checks if the sender has:
- A public profile picture (using
get_user_profile_photos) - A username set
- A public profile picture (using
- If either is missing:
- Warning mode (default): Sends a warning to the designated topic
- Restrict mode: Progressive enforcement (see below)
When RESTRICT_FAILED_USERS=true:
- First message → Warning sent to warning topic (mentions message and time thresholds)
- Messages 2 to (N-1) → Silent (no spam)
- Message N → User restricted, notification sent with DM link
Users are restricted when either:
- They send N messages (message threshold), OR
- X hours pass since first warning (time threshold)
Whichever happens first triggers the restriction.
The bot runs a JobQueue background job every 5 minutes that:
- Queries the database for warnings older than
WARNING_TIME_THRESHOLD_MINUTES - Restricts those users (applies mute permissions)
- Sends notifications to the warning topic with the DM link
- Marks them as restricted in the database
This ensures users cannot evade restrictions by simply not sending messages.
Admin IDs are fetched at startup and refreshed every 10 minutes via a JobQueue job. If the refresh fails for a group, the bot falls back to the previously cached data (never an empty list). Spam handlers use the cached admin IDs for fast lookups, while the topic guard uses live get_chat_member API calls for maximum accuracy.
All warning and restriction messages are centralized in constants.py for consistency:
WARNING_MESSAGE_NO_RESTRICTION: Used in warning-only modeWARNING_MESSAGE_WITH_THRESHOLD: Used in progressive restriction mode (first message)RESTRICTION_MESSAGE_AFTER_MESSAGES: Sent when message threshold is reachedRESTRICTION_MESSAGE_AFTER_TIME: Sent when time threshold is reachedformat_threshold_display(): Helper function that converts minutes to Indonesian format ("X jam" or "Y menit")
All messages are formatted with proper Indonesian language patterns and include links to group rules and bot DM for unrestriction appeals.
- Only group administrators and the bot itself can post in the warning topic
- Messages and edited messages from regular users are automatically deleted
- Uses
ApplicationHandlerStopto prevent downstream handlers from processing warning-topic traffic - Fail-closed: On API errors, messages in the warning topic are deleted (erring on the side of protection)
When a restricted user DMs the bot (or sends /start):
- Bot checks if user is in the group
- Bot checks if user now has complete profile (photo + username)
- If complete and user was restricted by the bot, restriction is lifted
- If user was restricted by an admin (not the bot), they're told to contact admin
| Variable | Description | Default |
|---|---|---|
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 |
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) |
CAPTCHA_ENABLED |
Enable captcha verification for new members | false |
CAPTCHA_TIMEOUT_SECONDS |
Seconds before kicking unverified members | 120 (2 minutes) |
NEW_USER_PROBATION_HOURS |
Hours new users can't send links/forwards | 72 (3 days) |
NEW_USER_VIOLATION_THRESHOLD |
Spam violations before restriction | 3 |
CONTACT_SPAM_RESTRICT |
Restrict users who share contact cards | true |
DUPLICATE_SPAM_ENABLED |
Enable duplicate-message detection | true |
DUPLICATE_SPAM_WINDOW_SECONDS |
Window to compare messages for duplicates | 120 |
DUPLICATE_SPAM_THRESHOLD |
Repeats within window before flagging | 2 |
DUPLICATE_SPAM_MIN_LENGTH |
Minimum message length considered | 20 |
DUPLICATE_SPAM_SIMILARITY |
Similarity ratio (0-1) to count as duplicate | 0.95 |
BIO_BAIT_ENABLED |
Enable bio-bait phrase/link detection | true |
BIO_BAIT_MONITOR_ONLY |
Log/alert only, skip delete + restrict | false |
BIO_BAIT_ALERT_CHAT_ID |
Chat ID to receive monitor-only detection alerts | None |
DATABASE_PATH |
SQLite database path | data/bot.db |
RULES_LINK |
Link to group rules message | https://t.me/pythonID/290029/321799 |
LOGFIRE_ENABLED |
Enable Logfire logging integration | true |
LOGFIRE_TOKEN |
Logfire API token (optional) | None |
LOG_LEVEL |
Logging level (DEBUG/INFO/WARNING/ERROR) | INFO |
GROUPS_CONFIG_PATH |
Path to groups.json for multi-group support |
None (single-group mode from .env) |
PLUGINS_DEFAULT |
Bot-wide plugin enable/disable defaults (JSON object, e.g. {"bio_bait_spam": false}) |
{} (all enabled) |
-
Warning Mode (default,
RESTRICT_FAILED_USERS=false): Users receive warnings but are not restricted. Useful for informing about rules without enforcement. -
Progressive Restriction Mode (
RESTRICT_FAILED_USERS=true): Users are restricted when either:- Message threshold (
WARNING_THRESHOLD): They send N messages with incomplete profile - Time threshold (
WARNING_TIME_THRESHOLD_MINUTES): X minutes pass since first warning
- Message threshold (
Both message-based and time-based restrictions work together. Users are restricted by whichever threshold is reached first.
For testing: Use WARNING_TIME_THRESHOLD_MINUTES=5 in .env.staging to test with 5-minute threshold instead of 3 hours.
- Ensure the bot is added as an admin to the group
- Verify
GROUP_IDis correct (should be negative, starting with-100) - Check that Topics are enabled in the group
- Verify
WARNING_TOPIC_IDis correct - Make sure the topic exists and hasn't been deleted
- The bot might not be in the group yet
- The group ID might be incorrect
- User must be a member of the group (not left/kicked)
- User must have been restricted by the bot, not by an admin
- User must have completed their profile (photo + username)
- Ensure
RESTRICT_FAILED_USERS=trueis set (or time-based restrictions are always active) - Check that
WARNING_TIME_THRESHOLD_MINUTESis set correctly - The JobQueue job runs every 5 minutes; initial restriction may take up to 5 minutes
- For testing, set
WARNING_TIME_THRESHOLD_MINUTES=5to test with 5-minute timeout - Check bot logs for scheduler errors
- The bot uses python-telegram-bot's built-in graceful shutdown handling
- When you press Ctrl+C or the process receives a termination signal:
- Polling stops accepting new updates
- JobQueue shuts down and waits for all background jobs to complete
- Application exits cleanly
Docker deployment tip: Docker will send SIGTERM to the bot, triggering graceful shutdown. The bot will clean up within the default timeout (10 seconds).
Example Docker commands:
# Start the bot
docker run -d --name pythonid-bot pythonid-bot
# Stop gracefully (SIGTERM sent, bot gracefully shuts down)
docker stop pythonid-bot
# Restart (sends SIGTERM, waits for exit, starts new container)
docker restart pythonid-botMIT