From 042cb64aa8a169ebf1d559e5ed35f880bf5b8c8e Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Sat, 14 Mar 2026 13:05:28 +0700 Subject: [PATCH 1/4] feat: add contact card spam handler for all members Block contact card (phone number) sharing for all non-admin members in monitored groups. Messages are deleted and a notification is sent to the warning topic. No restriction is applied. - Add has_contact() helper and handle_contact_spam() handler - Register handler in its own priority group (group=2) - Shift probation/duplicate/message handlers to groups 3/4/5 - Add CONTACT_SPAM_NOTIFICATION Indonesian template - Add 12 tests (531 total, all passing) --- AGENTS.md | 11 +-- src/bot/constants.py | 8 ++ src/bot/handlers/anti_spam.py | 92 +++++++++++++++++-- src/bot/main.py | 24 +++-- tests/test_anti_spam.py | 162 ++++++++++++++++++++++++++++++++++ 5 files changed, 280 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5480a52..c84622c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,12 +91,13 @@ PythonID/ ### 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 diff --git a/src/bot/constants.py b/src/bot/constants.py index 0dd4f2a..2f1f55e 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -211,6 +211,14 @@ 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" + "📌 [Peraturan Grup]({rules_link})" +) + # Duplicate message spam notification DUPLICATE_SPAM_RESTRICTION = ( "🚫 *Spam Pesan Duplikat*\n\n" diff --git a/src/bot/handlers/anti_spam.py b/src/bot/handlers/anti_spam.py index 5b06eac..34fec0a 100644 --- a/src/bot/handlers/anti_spam.py +++ b/src/bot/handlers/anti_spam.py @@ -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 @@ -15,6 +15,7 @@ from telegram.ext import ApplicationHandlerStop, ContextTypes from bot.constants import ( + CONTACT_SPAM_NOTIFICATION, INLINE_KEYBOARD_SPAM_NOTIFICATION, INLINE_KEYBOARD_SPAM_NOTIFICATION_NO_RESTRICT, NEW_USER_SPAM_RESTRICTION, @@ -241,6 +242,87 @@ 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, + ) + + try: + notification_text = CONTACT_SPAM_NOTIFICATION.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: diff --git a/src/bot/main.py b/src/bot/main.py index 4e15540..f59f59b 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -18,7 +18,7 @@ from bot.database.service import init_database from bot.group_config import get_group_registry, init_group_registry from bot.handlers import captcha -from bot.handlers.anti_spam import handle_inline_keyboard_spam, handle_new_user_spam +from bot.handlers.anti_spam import handle_contact_spam, handle_inline_keyboard_spam, handle_new_user_spam from bot.handlers.duplicate_spam import handle_duplicate_spam from bot.handlers.dm import handle_dm from bot.handlers.message import handle_message @@ -317,15 +317,25 @@ def main() -> None: ) logger.info("Registered handler: inline_keyboard_spam_handler (group=1)") + # Handler: Contact spam handler - blocks contact card sharing for all members + application.add_handler( + MessageHandler( + filters.ChatType.GROUPS & filters.CONTACT, + handle_contact_spam, + ), + group=2, + ) + logger.info("Registered handler: contact_spam_handler (group=2)") + # Handler 9: New-user anti-spam handler - checks for forwards/links from users on probation application.add_handler( MessageHandler( filters.ChatType.GROUPS, handle_new_user_spam, ), - group=2, + group=3, ) - logger.info("Registered handler: anti_spam_handler (group=2)") + logger.info("Registered handler: anti_spam_handler (group=3)") # Handler 10: Duplicate message spam handler - detects repeated identical messages application.add_handler( @@ -333,9 +343,9 @@ def main() -> None: filters.ChatType.GROUPS & ~filters.COMMAND, handle_duplicate_spam, ), - group=3, + group=4, ) - logger.info("Registered handler: duplicate_spam_handler (group=3)") + logger.info("Registered handler: duplicate_spam_handler (group=4)") # Handler 11: Group message handler - monitors messages in monitored # groups and warns/restricts users with incomplete profiles @@ -344,9 +354,9 @@ def main() -> None: filters.ChatType.GROUPS & ~filters.COMMAND, handle_message, ), - group=4, + group=5, ) - logger.info("Registered handler: message_handler (group=4)") + logger.info("Registered handler: message_handler (group=5)") # Register auto-restriction job to run every 5 minutes if application.job_queue: diff --git a/tests/test_anti_spam.py b/tests/test_anti_spam.py index 7a520fa..ae5c255 100644 --- a/tests/test_anti_spam.py +++ b/tests/test_anti_spam.py @@ -11,8 +11,10 @@ from bot.handlers.anti_spam import ( extract_urls, + handle_contact_spam, handle_inline_keyboard_spam, handle_new_user_spam, + has_contact, has_external_reply, has_link, has_non_whitelisted_inline_keyboard_urls, @@ -1163,3 +1165,163 @@ async def test_notification_without_restrict_on_restrict_failure( call_args = mock_context.bot.send_message.call_args assert "dibatasi" not in call_args.kwargs.get("text", call_args[1].get("text", "")) + + +class TestHasContact: + """Tests for the has_contact helper function.""" + + def test_contact_detected(self): + """Test that message with contact attribute returns True.""" + msg = MagicMock(spec=Message) + msg.contact = MagicMock() + + assert has_contact(msg) is True + + def test_no_contact_returns_false(self): + """Test that message without contact returns False.""" + msg = MagicMock(spec=Message) + msg.contact = None + + assert has_contact(msg) is False + + +class TestHandleContactSpam: + """Tests for the handle_contact_spam handler.""" + + @pytest.fixture + def mock_update(self): + """Create a mock update with a message containing a contact.""" + update = MagicMock() + update.message = MagicMock(spec=Message) + update.message.from_user = MagicMock(spec=User) + update.message.from_user.id = 12345 + update.message.from_user.is_bot = False + update.message.from_user.full_name = "Spam User" + update.message.from_user.username = "spamuser" + update.effective_chat = MagicMock(spec=Chat) + update.effective_chat.id = -100123456 + + update.message.contact = MagicMock() + update.message.delete = AsyncMock() + + return update + + @pytest.fixture + def mock_context(self): + """Create a mock context.""" + context = MagicMock() + context.bot = AsyncMock() + context.bot.send_message = AsyncMock() + context.bot.restrict_chat_member = AsyncMock() + context.bot_data = {"group_admin_ids": {}} + return context + + @pytest.fixture + def group_config(self): + """Create group config for contact spam tests.""" + return GroupConfig( + group_id=-100123456, + warning_topic_id=123, + rules_link="https://example.com/rules", + ) + + async def test_contact_spam_deleted_and_notified( + self, mock_update, mock_context, group_config + ): + """Test that contact message is deleted + notification sent + ApplicationHandlerStop raised.""" + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_contact_spam(mock_update, mock_context) + + mock_update.message.delete.assert_called_once() + mock_context.bot.send_message.assert_called_once() + mock_context.bot.restrict_chat_member.assert_not_called() + + async def test_no_contact_returns_early( + self, mock_update, mock_context, group_config + ): + """Test that messages without contact are ignored.""" + mock_update.message.contact = None + + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + await handle_contact_spam(mock_update, mock_context) + + mock_update.message.delete.assert_not_called() + + async def test_non_group_message_returns_early( + self, mock_update, mock_context + ): + """Test that non-group messages are ignored.""" + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=None): + await handle_contact_spam(mock_update, mock_context) + + mock_update.message.delete.assert_not_called() + + async def test_bot_user_returns_early( + self, mock_update, mock_context, group_config + ): + """Test that bot messages are ignored.""" + mock_update.message.from_user.is_bot = True + + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + await handle_contact_spam(mock_update, mock_context) + + mock_update.message.delete.assert_not_called() + + async def test_no_message_returns_early(self, mock_context): + """Test that update without message is ignored.""" + mock_update = MagicMock() + mock_update.message = None + + await handle_contact_spam(mock_update, mock_context) + + async def test_no_from_user_returns_early(self, mock_context): + """Test that message without from_user is ignored.""" + mock_update = MagicMock() + mock_update.message = MagicMock(spec=Message) + mock_update.message.from_user = None + + await handle_contact_spam(mock_update, mock_context) + + async def test_admin_user_returns_early(self, mock_update, mock_context, group_config): + """Test that admin user with contact is NOT blocked.""" + mock_context.bot_data = { + "group_admin_ids": {group_config.group_id: [mock_update.message.from_user.id]} + } + + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + await handle_contact_spam(mock_update, mock_context) + + mock_update.message.delete.assert_not_called() + + async def test_continues_when_delete_fails( + self, mock_update, mock_context, group_config + ): + """Test that handler continues when message delete fails.""" + mock_update.message.delete = AsyncMock(side_effect=Exception("Delete failed")) + + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_contact_spam(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + + async def test_continues_when_send_notification_fails( + self, mock_update, mock_context, group_config + ): + """Test that handler completes when sending notification fails.""" + mock_context.bot.send_message = AsyncMock( + side_effect=Exception("Send failed") + ) + + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_contact_spam(mock_update, mock_context) + + mock_update.message.delete.assert_called_once() + + async def test_raises_application_handler_stop(self, mock_update, mock_context, group_config): + """Test that handler raises ApplicationHandlerStop after processing spam.""" + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_contact_spam(mock_update, mock_context) From 939f6917028724fd6cd15ac8f8cb2e96873570dd Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Sat, 14 Mar 2026 13:07:51 +0700 Subject: [PATCH 2/4] docs: update AGENTS.md and README.md for contact spam handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update handler descriptions and priority group numbers - Add contact card blocking to features list - Update test counts (519 → 531) - Add contact spam flow to mermaid workflow diagram - Update architecture section with new handler groups --- AGENTS.md | 8 ++++---- README.md | 30 ++++++++++++++++++------------ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c84622c..c54bcc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) @@ -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 | @@ -83,7 +83,7 @@ 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 @@ -157,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 (531 tests) — check before committing ## Anti-Patterns (THIS PROJECT) diff --git a/README.md b/README.md index 7e52319..06d91c5 100644 --- a/README.md +++ b/README.md @@ -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 - **Anti-spam enforcement**: Tracks violations and restricts spammers after threshold ### Admin Tools @@ -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**: 531 total +- **Pass Rate**: 100% (531/531 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% @@ -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 @@ -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 @@ -297,9 +298,14 @@ flowchart TD CaptchaAnswer -->|Timeout| KickMember[Keep Restricted] KickMember --> UpdateMessage[Update Challenge Message] - %% Anti-Spam Flow (New User Probation) - UpdateType -->|Group Message| CheckProbation{User On
Probation?} - CheckProbation -->|No| CheckBot + %% Anti-Spam Flow (Contact Card + New User Probation) + UpdateType -->|Group Message| CheckContact{Has Contact
Card?} + CheckContact -->|Yes| CheckContactAdmin{Is Admin?} + CheckContactAdmin -->|Yes| CheckProbation + CheckContactAdmin -->|No| DeleteContact[Delete Contact Message] + DeleteContact --> SendContactNotify[Send Contact
Spam Notification] + CheckContact -->|No| CheckProbation + CheckProbation{User On
Probation?} -->|No| CheckBot CheckProbation -->|Yes| CheckExpired{Probation
Expired?} CheckExpired -->|Yes| ClearProbation[(Clear Probation)] CheckExpired -->|No| CheckViolation{Forward/Link/
External Reply?} @@ -429,9 +435,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,SendContactNotify actionNode class End1,End2,End3,End4,End5,End6,End7,End8,End9,End10,EndJob,StartProbation endNode class Start startNode ``` @@ -445,11 +451,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 From c6ff52853793da7ea3dbe4c5430b6bdb02c8318a Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Sat, 14 Mar 2026 13:12:41 +0700 Subject: [PATCH 3/4] feat: restrict users on contact spam, update docs - Add user restriction to contact spam handler (delete + restrict + notify) - Add CONTACT_SPAM_NOTIFICATION_NO_RESTRICT fallback template - Add tests for restrict behavior and restrict failure (533 total) - Update README mermaid diagram with restrict step - Update test counts in AGENTS.md and README.md --- AGENTS.md | 2 +- README.md | 11 ++++++----- src/bot/constants.py | 8 ++++++++ src/bot/handlers/anti_spam.py | 22 ++++++++++++++++++++- tests/test_anti_spam.py | 37 ++++++++++++++++++++++++++++++++--- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c54bcc6..c9984d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,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 (531 tests) — check before committing +- **Coverage**: 99.9% maintained (533 tests) — check before committing ## Anti-Patterns (THIS PROJECT) diff --git a/README.md b/README.md index 06d91c5..7c01953 100644 --- a/README.md +++ b/README.md @@ -22,7 +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 +- **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 @@ -196,8 +196,8 @@ uv run pytest -v The project maintains comprehensive test coverage: - **Coverage**: 99.9% (1,570 statements, 1 unreachable line) -- **Tests**: 531 total -- **Pass Rate**: 100% (531/531 passed) +- **Tests**: 533 total +- **Pass Rate**: 100% (533/533 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% @@ -303,7 +303,8 @@ flowchart TD CheckContact -->|Yes| CheckContactAdmin{Is Admin?} CheckContactAdmin -->|Yes| CheckProbation CheckContactAdmin -->|No| DeleteContact[Delete Contact Message] - DeleteContact --> SendContactNotify[Send Contact
Spam Notification] + DeleteContact --> RestrictContact[Restrict User] + RestrictContact --> SendContactNotify[Send Contact
Spam Notification] CheckContact -->|No| CheckProbation CheckProbation{User On
Probation?} -->|No| CheckBot CheckProbation -->|Yes| CheckExpired{Probation
Expired?} @@ -437,7 +438,7 @@ flowchart TD 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,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,DeleteContact,SendContactNotify 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 ``` diff --git a/src/bot/constants.py b/src/bot/constants.py index 2f1f55e..eb0001b 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -213,6 +213,14 @@ def format_hours_display(hours: int) -> str: # 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" diff --git a/src/bot/handlers/anti_spam.py b/src/bot/handlers/anti_spam.py index 34fec0a..4f1dc2c 100644 --- a/src/bot/handlers/anti_spam.py +++ b/src/bot/handlers/anti_spam.py @@ -16,6 +16,7 @@ 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, @@ -302,8 +303,27 @@ async def handle_contact_spam( exc_info=True, ) + restricted = False + 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: - notification_text = CONTACT_SPAM_NOTIFICATION.format( + 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, ) diff --git a/tests/test_anti_spam.py b/tests/test_anti_spam.py index ae5c255..38b246b 100644 --- a/tests/test_anti_spam.py +++ b/tests/test_anti_spam.py @@ -1225,17 +1225,17 @@ def group_config(self): rules_link="https://example.com/rules", ) - async def test_contact_spam_deleted_and_notified( + async def test_contact_spam_deleted_and_restricted( self, mock_update, mock_context, group_config ): - """Test that contact message is deleted + notification sent + ApplicationHandlerStop raised.""" + """Test that contact message is deleted, user restricted, and notification sent.""" with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): with pytest.raises(ApplicationHandlerStop): await handle_contact_spam(mock_update, mock_context) mock_update.message.delete.assert_called_once() + mock_context.bot.restrict_chat_member.assert_called_once() mock_context.bot.send_message.assert_called_once() - mock_context.bot.restrict_chat_member.assert_not_called() async def test_no_contact_returns_early( self, mock_update, mock_context, group_config @@ -1304,6 +1304,22 @@ async def test_continues_when_delete_fails( with pytest.raises(ApplicationHandlerStop): await handle_contact_spam(mock_update, mock_context) + mock_context.bot.restrict_chat_member.assert_called_once() + mock_context.bot.send_message.assert_called_once() + + async def test_continues_when_restrict_fails( + self, mock_update, mock_context, group_config + ): + """Test that handler continues when restrict fails.""" + mock_context.bot.restrict_chat_member = AsyncMock( + side_effect=Exception("Restrict failed") + ) + + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_contact_spam(mock_update, mock_context) + + mock_update.message.delete.assert_called_once() mock_context.bot.send_message.assert_called_once() async def test_continues_when_send_notification_fails( @@ -1320,6 +1336,21 @@ async def test_continues_when_send_notification_fails( mock_update.message.delete.assert_called_once() + async def test_notification_without_restrict_on_restrict_failure( + self, mock_update, mock_context, group_config + ): + """Test that notification uses different template when restrict fails.""" + mock_context.bot.restrict_chat_member = AsyncMock( + side_effect=Exception("Restrict failed") + ) + + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_contact_spam(mock_update, mock_context) + + call_args = mock_context.bot.send_message.call_args + assert "dibatasi" not in call_args.kwargs.get("text", call_args[1].get("text", "")) + async def test_raises_application_handler_stop(self, mock_update, mock_context, group_config): """Test that handler raises ApplicationHandlerStop after processing spam.""" with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): From afb3e812aaf2b6fd220be0e6ec9795dc96511d65 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Sat, 14 Mar 2026 13:22:41 +0700 Subject: [PATCH 4/4] feat: add contact_spam_restrict per-group config option - Add contact_spam_restrict field to GroupConfig (default: true) - Add .env fallback in config.py and build_group_registry - Skip restrict in handler when config is false - Add to groups.json.example and .env.example - Add test for disabled restrict config (534 total) - Update docs with new config option --- .env.example | 5 +++++ AGENTS.md | 2 +- README.md | 5 +++-- groups.json.example | 2 ++ src/bot/config.py | 1 + src/bot/group_config.py | 2 ++ src/bot/handlers/anti_spam.py | 27 ++++++++++++++------------- tests/test_anti_spam.py | 21 +++++++++++++++++++++ 8 files changed, 49 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 94113e0..4cb6079 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index c9984d2..7cdbe01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,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 (533 tests) — check before committing +- **Coverage**: 99.9% maintained (534 tests) — check before committing ## Anti-Patterns (THIS PROJECT) diff --git a/README.md b/README.md index 7c01953..4826dd6 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,8 @@ uv run pytest -v The project maintains comprehensive test coverage: - **Coverage**: 99.9% (1,570 statements, 1 unreachable line) -- **Tests**: 533 total -- **Pass Rate**: 100% (533/533 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% @@ -542,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` | diff --git a/groups.json.example b/groups.json.example index 7cbd0f5..b2f99ba 100644 --- a/groups.json.example +++ b/groups.json.example @@ -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, @@ -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, diff --git a/src/bot/config.py b/src/bot/config.py index 5ec1e6c..285d8c5 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -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 diff --git a/src/bot/group_config.py b/src/bot/group_config.py index b97210e..224d70a 100644 --- a/src/bot/group_config.py +++ b/src/bot/group_config.py @@ -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 @@ -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, diff --git a/src/bot/handlers/anti_spam.py b/src/bot/handlers/anti_spam.py index 4f1dc2c..5c98e4c 100644 --- a/src/bot/handlers/anti_spam.py +++ b/src/bot/handlers/anti_spam.py @@ -304,19 +304,20 @@ async def handle_contact_spam( ) restricted = False - 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, - ) + 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 = ( diff --git a/tests/test_anti_spam.py b/tests/test_anti_spam.py index 38b246b..e92806d 100644 --- a/tests/test_anti_spam.py +++ b/tests/test_anti_spam.py @@ -1356,3 +1356,24 @@ async def test_raises_application_handler_stop(self, mock_update, mock_context, with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): with pytest.raises(ApplicationHandlerStop): await handle_contact_spam(mock_update, mock_context) + + async def test_contact_spam_no_restrict_when_config_disabled( + self, mock_update, mock_context + ): + """Test that user is NOT restricted when contact_spam_restrict is False.""" + group_config = GroupConfig( + group_id=-100123456, + warning_topic_id=123, + rules_link="https://example.com/rules", + contact_spam_restrict=False, + ) + + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_contact_spam(mock_update, mock_context) + + mock_update.message.delete.assert_called_once() + mock_context.bot.restrict_chat_member.assert_not_called() + mock_context.bot.send_message.assert_called_once() + call_args = mock_context.bot.send_message.call_args + assert "dibatasi" not in call_args.kwargs.get("text", call_args[1].get("text", ""))