Skip to content
Open
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 @@ -36,6 +36,11 @@ CAPTCHA_ENABLED=false
# Default: 120 seconds (2 minutes)
CAPTCHA_TIMEOUT_SECONDS=120

# Restrict users who share contact cards (true/false)
# When true, users sharing contact cards will be restricted (muted)
# When false, contact messages are deleted but user is not restricted
CONTACT_SPAM_RESTRICT=true

# Enable duplicate message spam detection (true/false)
# Detects and restricts users who paste the same message repeatedly
DUPLICATE_SPAM_ENABLED=true
Expand Down
19 changes: 10 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ PythonID/
│ │ ├── captcha.py # New member verification flow
│ │ ├── verify.py # Admin /verify, /unverify commands
│ │ ├── check.py # Admin /check command + forwarded message handling
│ │ ├── anti_spam.py # Probation enforcement (links/forwards)
│ │ ├── anti_spam.py # Anti-spam (contact cards, inline keyboards, probation)
│ │ ├── message.py # Profile compliance monitoring
│ │ ├── dm.py # DM unrestriction flow
│ │ └── topic_guard.py # Warning topic protection (group=-1)
Expand All @@ -66,7 +66,7 @@ PythonID/

| Task | Location | Notes |
|------|----------|-------|
| Add new handler | `main.py` | Register with appropriate group (-1, 0, 1) |
| Add new handler | `main.py` | Register with appropriate group (-1, 0, 1-5) |
| 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 @@ -83,20 +83,21 @@ PythonID/
| `constants.py` | 530 | Templates + massive whitelists (Indonesian tech community) |
| `handlers/captcha.py` | 375 | New member join → restrict → verify → unrestrict lifecycle |
| `handlers/verify.py` | 358 | Admin verification commands + inline button callbacks |
| `handlers/anti_spam.py` | 326 | Probation enforcement with URL whitelisting |
| `handlers/anti_spam.py` | 420 | Anti-spam: contact cards, inline keyboards, probation enforcement |
| `main.py` | 315 | Entry point, logging, handler registration, JobQueue setup |

## Architecture Patterns

### Handler Priority Groups
```python
# main.py - Order matters!
group=-1 # topic_guard: Runs FIRST, deletes unauthorized warning topic msgs, raises ApplicationHandlerStop
group=0 # Commands, DM, captcha: Default priority
group=-1 # topic_guard: Runs FIRST
group=0 # Commands, DM, captcha
group=1 # inline_keyboard_spam: Catches inline keyboard URL spam
group=2 # new_user_spam: Probation enforcement (links/forwards)
group=3 # duplicate_spam: Repeated message detection
group=4 # message_handler: Runs LAST, profile compliance check
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
```

### Topic Guard Design
Expand Down Expand Up @@ -156,7 +157,7 @@ Time threshold → Auto-restrict via scheduler (parallel path)
- **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
- **Coverage**: 99.9% maintained (519 tests) — check before committing
- **Coverage**: 99.9% maintained (534 tests) — check before committing

## Anti-Patterns (THIS PROJECT)

Expand Down
32 changes: 20 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ A comprehensive Telegram bot for managing group members with profile verificatio
- **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)
- **Anti-spam enforcement**: Tracks violations and restricts spammers after threshold

### Admin Tools
Expand Down Expand Up @@ -195,8 +196,8 @@ uv run pytest -v

The project maintains comprehensive test coverage:
- **Coverage**: 99.9% (1,570 statements, 1 unreachable line)
- **Tests**: 519 total
- **Pass Rate**: 100% (519/519 passed)
- **Tests**: 534 total
- **Pass Rate**: 100% (534/534 passed)
- **All modules at 100%** except one unreachable line in `anti_spam.py`
- Services: `bot_info.py`, `scheduler.py`, `user_checker.py`, `telegram_utils.py`, `captcha_recovery.py` — all 100%
- Handlers: `anti_spam.py` (99%), `captcha.py`, `check.py`, `dm.py`, `message.py`, `topic_guard.py`, `verify.py`, `duplicate_spam.py` — all 100%
Expand All @@ -209,7 +210,7 @@ All modules are fully unit tested with:
- 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)
- Anti-spam protection (forwarded messages, URL whitelisting, external replies)
- Anti-spam protection (contact cards, inline keyboards, forwarded messages, URL whitelisting, external replies)

## Project Structure

Expand Down Expand Up @@ -248,7 +249,7 @@ PythonID/
├── constants.py # Shared constants
├── group_config.py # Multi-group configuration (GroupConfig, GroupRegistry)
├── handlers/
│ ├── anti_spam.py # Anti-spam handler for probation users
│ ├── anti_spam.py # Anti-spam (contact cards, inline keyboards, probation)
│ ├── captcha.py # Captcha verification handler
│ ├── dm.py # DM unrestriction handler
│ ├── message.py # Group message handler
Expand Down Expand Up @@ -297,9 +298,15 @@ flowchart TD
CaptchaAnswer -->|Timeout| KickMember[Keep Restricted]
KickMember --> UpdateMessage[Update Challenge Message]

%% Anti-Spam Flow (New User Probation)
UpdateType -->|Group Message| CheckProbation{User On<br/>Probation?}
CheckProbation -->|No| CheckBot
%% Anti-Spam Flow (Contact Card + New User Probation)
UpdateType -->|Group Message| CheckContact{Has Contact<br/>Card?}
CheckContact -->|Yes| CheckContactAdmin{Is Admin?}
CheckContactAdmin -->|Yes| CheckProbation
CheckContactAdmin -->|No| DeleteContact[Delete Contact Message]
DeleteContact --> RestrictContact[Restrict User]
RestrictContact --> SendContactNotify[Send Contact<br/>Spam Notification]
CheckContact -->|No| CheckProbation
CheckProbation{User On<br/>Probation?} -->|No| CheckBot
CheckProbation -->|Yes| CheckExpired{Probation<br/>Expired?}
CheckExpired -->|Yes| ClearProbation[(Clear Probation)]
CheckExpired -->|No| CheckViolation{Forward/Link/<br/>External Reply?}
Expand Down Expand Up @@ -429,9 +436,9 @@ flowchart TD
classDef startNode fill:#1a5f7a,stroke:#16213e,color:#eee

class Init,FetchAdmins,RecoverPending,StartJobs,Poll,CheckProfile,CheckDMProfile,RestrictAndChallenge,StorePending,ScheduleTimeout,WaitCaptcha,StartProbation,StartProbationAfter processNode
class UpdateType,RecoverCaptcha,TopicGuard,IsAdmin,CheckBot,CheckWhitelist,ProfileComplete,CheckMode,CheckCount,CheckInGroup,CheckPendingCaptcha,DMProfileComplete,CheckBotRestricted,CheckCurrentStatus,HasExpired,CheckKicked,NextUser,CheckAdminVerify,CheckAdminUnverify,CaptchaAnswer,CheckCaptchaEnabled,CheckProbation,CheckExpired,CheckViolation,CheckWhitelisted,ViolationCount,CheckWarningsExist,CheckAdminForward,ExtractUser decisionNode
class UpdateType,RecoverCaptcha,TopicGuard,IsAdmin,CheckBot,CheckWhitelist,ProfileComplete,CheckMode,CheckCount,CheckInGroup,CheckPendingCaptcha,DMProfileComplete,CheckBotRestricted,CheckCurrentStatus,HasExpired,CheckKicked,NextUser,CheckAdminVerify,CheckAdminUnverify,CaptchaAnswer,CheckCaptchaEnabled,CheckProbation,CheckExpired,CheckViolation,CheckWhitelisted,ViolationCount,CheckWarningsExist,CheckAdminForward,ExtractUser,CheckContact,CheckContactAdmin decisionNode
class IncrementDB,SilentIncrement,MarkRestricted,ClearRecord,ClearRecord2,QueryDB,ClearKicked,MarkTimeRestricted,AddWhitelist,RemoveWhitelist,IncrementViolation,ClearProbation,DeleteWarnings dataNode
class DeleteMsg,SendWarning,SendFirstWarning,RestrictUser,SendRestrictionMsg,SendNotInGroup,SendCaptchaRedirect,SendMissing,SendNoRestriction,SendAlreadyUnrestricted,UnrestrictUser,SendSuccess,ApplyTimeRestriction,SendTimeNotice,SchedulerJob,SendVerifySuccess,SendUnverifySuccess,DenyVerify,DenyUnverify,UnrestrictMember,KickMember,UpdateMessage,CancelTimeout,ShowError,DeleteSpam,SendSpamWarning,RestrictSpammer,SendSpamRestriction,UnrestrictVerified,SendClearance,DenyForward,SendButtons,SendExtractError,ProcessVerify,ProcessUnverify actionNode
class DeleteMsg,SendWarning,SendFirstWarning,RestrictUser,SendRestrictionMsg,SendNotInGroup,SendCaptchaRedirect,SendMissing,SendNoRestriction,SendAlreadyUnrestricted,UnrestrictUser,SendSuccess,ApplyTimeRestriction,SendTimeNotice,SchedulerJob,SendVerifySuccess,SendUnverifySuccess,DenyVerify,DenyUnverify,UnrestrictMember,KickMember,UpdateMessage,CancelTimeout,ShowError,DeleteSpam,SendSpamWarning,RestrictSpammer,SendSpamRestriction,UnrestrictVerified,SendClearance,DenyForward,SendButtons,SendExtractError,ProcessVerify,ProcessUnverify,DeleteContact,RestrictContact,SendContactNotify actionNode
class End1,End2,End3,End4,End5,End6,End7,End8,End9,End10,EndJob,StartProbation endNode
class Start startNode
```
Expand All @@ -445,11 +452,11 @@ The bot is organized into clear modules for maintainability:
- **main.py**: Entry point with python-telegram-bot's JobQueue integration, admin cache refresh, and graceful shutdown
- **handlers/**: Message processing logic (priority groups -1 through 4)
- `topic_guard.py`: Protects warning topic (group=-1, messages + edited messages, fail-closed)
- `message.py`: Monitors group messages and sends warnings/restrictions (group=4)
- `message.py`: Monitors group messages and sends warnings/restrictions (group=5)
- `dm.py`: Handles DM unrestriction flow
- `captcha.py`: Captcha verification for new members
- `anti_spam.py`: Inline keyboard spam (group=1) + new user probation enforcement (group=2)
- `duplicate_spam.py`: Repeated message detection (group=3)
- `anti_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)
- `verify.py`: /verify and /unverify command handlers
- `check.py`: /check command + forwarded message handling
- **services/**: Business logic and utilities
Expand Down Expand Up @@ -535,6 +542,7 @@ When a restricted user DMs the bot (or sends `/start`):
| `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` |
| `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` |
Expand Down
2 changes: 2 additions & 0 deletions groups.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"new_user_probation_hours": 72,
"new_user_violation_threshold": 3,
"rules_link": "https://t.me/pythonID/290029/321799",
"contact_spam_restrict": true,
"duplicate_spam_enabled": true,
"duplicate_spam_window_seconds": 120,
"duplicate_spam_threshold": 2,
Expand All @@ -27,6 +28,7 @@
"new_user_probation_hours": 168,
"new_user_violation_threshold": 2,
"rules_link": "https://t.me/mygroup/rules",
"contact_spam_restrict": true,
"duplicate_spam_enabled": true,
"duplicate_spam_window_seconds": 60,
"duplicate_spam_threshold": 2,
Expand Down
1 change: 1 addition & 0 deletions src/bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class Settings(BaseSettings):
captcha_timeout_seconds: int = 120
new_user_probation_hours: int = 72 # 3 days default
new_user_violation_threshold: int = 3 # restrict after this many violations
contact_spam_restrict: bool = True
duplicate_spam_enabled: bool = True
duplicate_spam_window_seconds: int = 120
duplicate_spam_threshold: int = 2
Expand Down
16 changes: 16 additions & 0 deletions src/bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,22 @@ def format_hours_display(hours: int) -> str:
"📌 [Peraturan Grup]({rules_link})"
)

# Contact spam notification
CONTACT_SPAM_NOTIFICATION = (
"🚫 *Kontak Dihapus*\n\n"
"Pesan kontak dari {user_mention} telah dihapus karena berbagi kontak/"
"nomor telepon tidak diperbolehkan di grup ini.\n\n"
"Pengguna telah dibatasi.\n\n"
"📌 [Peraturan Grup]({rules_link})"
)

CONTACT_SPAM_NOTIFICATION_NO_RESTRICT = (
"🚫 *Kontak Dihapus*\n\n"
"Pesan kontak dari {user_mention} telah dihapus karena berbagi kontak/"
"nomor telepon tidak diperbolehkan di grup ini.\n\n"
"📌 [Peraturan Grup]({rules_link})"
)

# Duplicate message spam notification
DUPLICATE_SPAM_RESTRICTION = (
"🚫 *Spam Pesan Duplikat*\n\n"
Expand Down
2 changes: 2 additions & 0 deletions src/bot/group_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class GroupConfig(BaseModel):
new_user_probation_hours: int = 72
new_user_violation_threshold: int = 3
rules_link: str = "https://t.me/pythonID/290029/321799"
contact_spam_restrict: bool = True
duplicate_spam_enabled: bool = True
duplicate_spam_window_seconds: int = 120
duplicate_spam_threshold: int = 2
Expand Down Expand Up @@ -186,6 +187,7 @@ def build_group_registry(settings: object) -> GroupRegistry:
new_user_probation_hours=settings.new_user_probation_hours,
new_user_violation_threshold=settings.new_user_violation_threshold,
rules_link=settings.rules_link,
contact_spam_restrict=settings.contact_spam_restrict,
duplicate_spam_enabled=settings.duplicate_spam_enabled,
duplicate_spam_window_seconds=settings.duplicate_spam_window_seconds,
duplicate_spam_threshold=settings.duplicate_spam_threshold,
Expand Down
113 changes: 108 additions & 5 deletions src/bot/handlers/anti_spam.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""
Anti-spam handler for new users on probation.
Anti-spam handlers for group content moderation.

This module enforces anti-spam rules for newly joined users. During the
probation period, users cannot send forwarded messages or links. Violations
result in message deletion, a warning on first offense, and restriction
after exceeding the threshold.
This module enforces anti-spam rules including:
- Contact card spam detection (all members)
- Inline keyboard URL spam detection (all members)
- Probation enforcement for new users (forwards, links, external replies, stories)
"""

import logging
Expand All @@ -15,6 +15,8 @@
from telegram.ext import ApplicationHandlerStop, ContextTypes

from bot.constants import (
CONTACT_SPAM_NOTIFICATION,
CONTACT_SPAM_NOTIFICATION_NO_RESTRICT,
INLINE_KEYBOARD_SPAM_NOTIFICATION,
INLINE_KEYBOARD_SPAM_NOTIFICATION_NO_RESTRICT,
NEW_USER_SPAM_RESTRICTION,
Expand Down Expand Up @@ -241,6 +243,107 @@ def has_non_whitelisted_inline_keyboard_urls(message: Message) -> bool:
return False


def has_contact(message: Message) -> bool:
"""
Check if a message contains a contact card.

Args:
message: Telegram message to check.

Returns:
bool: True if message contains a contact.
"""
return message.contact is not None


async def handle_contact_spam(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""
Handle contact card sharing in monitored groups.

Blocks ALL non-admin members from sending contact cards.
Deletes the message and sends a notification to the warning topic.

Args:
update: Telegram update containing the message.
context: Bot context with helper methods.
"""
if not update.message or not update.message.from_user:
return

group_config = get_group_config_for_update(update)
if group_config is None:
return

user = update.message.from_user
if user.is_bot:
return

admin_ids = context.bot_data.get("group_admin_ids", {}).get(group_config.group_id, [])
if user.id in admin_ids:
return

msg = update.message
if not has_contact(msg):
return

user_mention = get_user_mention(user)
logger.info(
f"Contact spam detected: user_id={user.id}, "
f"group_id={group_config.group_id}"
)

try:
await msg.delete()
logger.info(f"Deleted contact spam from user_id={user.id}")
except Exception:
logger.error(
f"Failed to delete contact spam: user_id={user.id}",
exc_info=True,
)

restricted = False
if group_config.contact_spam_restrict:
try:
await context.bot.restrict_chat_member(
chat_id=group_config.group_id,
user_id=user.id,
permissions=RESTRICTED_PERMISSIONS,
)
restricted = True
logger.info(f"Restricted user_id={user.id} for contact spam")
except Exception:
logger.error(
f"Failed to restrict user for contact spam: user_id={user.id}",
exc_info=True,
)

try:
template = (
CONTACT_SPAM_NOTIFICATION if restricted
else CONTACT_SPAM_NOTIFICATION_NO_RESTRICT
)
notification_text = template.format(
user_mention=user_mention,
rules_link=group_config.rules_link,
)
await context.bot.send_message(
chat_id=group_config.group_id,
message_thread_id=group_config.warning_topic_id,
text=notification_text,
parse_mode="Markdown",
)
logger.info(f"Sent contact spam notification for user_id={user.id}")
except Exception:
logger.error(
f"Failed to send contact spam notification: user_id={user.id}",
exc_info=True,
)

raise ApplicationHandlerStop


async def handle_inline_keyboard_spam(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
Expand Down
Loading
Loading