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 5480a52..7cdbe01 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
@@ -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
@@ -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)
diff --git a/README.md b/README.md
index 7e52319..4826dd6 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 (delete + restrict)
- **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**: 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%
@@ -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,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
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 --> RestrictContact[Restrict User]
+ RestrictContact --> 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 +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
```
@@ -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
@@ -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` |
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/constants.py b/src/bot/constants.py
index 0dd4f2a..eb0001b 100644
--- a/src/bot/constants.py
+++ b/src/bot/constants.py
@@ -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"
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 5b06eac..5c98e4c 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,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,
@@ -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:
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..e92806d 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,215 @@ 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_restricted(
+ self, mock_update, mock_context, group_config
+ ):
+ """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()
+
+ 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.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(
+ 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_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):
+ 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", ""))