Skip to content

Commit 9448844

Browse files
committed
feat: block media during new user probation
Add media detection (photo, video, animation, audio, voice, video note) to anti-spam probation enforcement. New users in probation period are now restricted from sending media attachments alongside existing restrictions on forwarded messages, links, and external replies. - Add has_media() helper to detect media attachments - Integrate media check into handle_new_user_spam handler - Update Indonesian warning/restriction templates - Add unit and handler-level tests for all media types
1 parent 9c95487 commit 9448844

3 files changed

Lines changed: 292 additions & 6 deletions

File tree

src/bot/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def format_hours_display(hours: int) -> str:
182182
# Anti-spam probation warning for new users
183183
NEW_USER_SPAM_WARNING = (
184184
"⚠️ {user_mention} baru bergabung dan sedang dalam masa percobaan.\n"
185-
"Selama {probation_display}, kamu tidak boleh meneruskan pesan atau mengirim tautan.\n"
185+
"Selama {probation_display}, kamu tidak boleh mengirim media (foto, video, audio, dll.), meneruskan pesan, atau mengirim tautan.\n"
186186
"Pesan yang melanggar akan dihapus dan kamu bisa dibatasi jika terus mengulang.\n"
187187
"Hubungi admin jika kamu membutuhkan bantuan.\n\n"
188188
"📖 [Baca aturan grup]({rules_link})"
@@ -191,7 +191,7 @@ def format_hours_display(hours: int) -> str:
191191
# Anti-spam restriction message when user exceeds violation threshold
192192
NEW_USER_SPAM_RESTRICTION = (
193193
"🚫 {user_mention} telah dibatasi karena mengirim pesan terlarang "
194-
"(forward/link/quote eksternal) sebanyak {violation_count} kali selama masa percobaan.\n\n"
194+
"(media/file/forward/link/quote eksternal) sebanyak {violation_count} kali selama masa percobaan.\n\n"
195195
"📖 [Baca aturan grup]({rules_link})"
196196
)
197197

src/bot/handlers/anti_spam.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
This module enforces anti-spam rules including:
55
- Contact card spam detection (all members)
66
- Inline keyboard URL spam detection (all members)
7-
- Probation enforcement for new users (forwards, links, external replies, stories)
7+
- Probation enforcement for new users (forwards, links, external replies, stories, media)
88
"""
99

1010
import logging
@@ -96,6 +96,31 @@ def has_story(message: Message) -> bool:
9696
return message.story is not None
9797

9898

99+
def has_media(message: Message) -> bool:
100+
"""
101+
Check if a message contains media attachments.
102+
103+
Media elements (photos, videos, animations, audio, voice, and video
104+
notes) are often used in spam or can be disruptive when sent by brand
105+
new users before they have passed their probation period.
106+
107+
Args:
108+
message: Telegram message to check.
109+
110+
Returns:
111+
bool: True if message contains a photo, video, animation, audio,
112+
voice, or video note.
113+
"""
114+
return any([
115+
message.photo,
116+
message.video,
117+
message.animation,
118+
message.audio,
119+
message.voice,
120+
message.video_note,
121+
])
122+
123+
99124
def extract_urls(message: Message) -> list[str]:
100125
"""
101126
Extract all URLs from a message.
@@ -486,21 +511,23 @@ async def handle_new_user_spam(
486511
msg = update.message
487512
user_mention = get_user_mention(user)
488513

489-
# Check for violations (forwarded message or non-whitelisted link or external reply)
514+
# Check for violations (forwarded message or non-whitelisted link or external reply or media)
490515
if not (
491516
is_forwarded(msg)
492517
or has_non_whitelisted_link(msg)
493518
or has_external_reply(msg)
494519
or has_story(msg)
495520
or has_non_whitelisted_inline_keyboard_urls(msg)
521+
or has_media(msg)
496522
):
497523
return # Not a violation
498524

499525
logger.info(
500526
f"Probation violation detected: user_id={user.id}, "
501527
f"forwarded={is_forwarded(msg)}, has_non_whitelisted_link={has_non_whitelisted_link(msg)}, "
502528
f"external_reply={has_external_reply(msg)}, has_story={has_story(msg)}, "
503-
f"inline_keyboard_spam={has_non_whitelisted_inline_keyboard_urls(msg)}"
529+
f"inline_keyboard_spam={has_non_whitelisted_inline_keyboard_urls(msg)}, "
530+
f"has_media={has_media(msg)}"
504531
)
505532

506533
# 1. Delete the violating message

tests/test_anti_spam.py

Lines changed: 260 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
has_contact,
1818
has_external_reply,
1919
has_link,
20+
has_media,
2021
has_non_whitelisted_inline_keyboard_urls,
2122
has_non_whitelisted_link,
2223
has_story,
@@ -135,6 +136,101 @@ def test_no_story_returns_false(self):
135136
assert has_story(msg) is False
136137

137138

139+
class TestHasMedia:
140+
"""Tests for the has_media helper function."""
141+
142+
def test_photo_detected(self):
143+
"""Test that message with photo is detected."""
144+
msg = MagicMock(spec=Message)
145+
msg.photo = [MagicMock()]
146+
msg.video = None
147+
msg.animation = None
148+
msg.document = None
149+
msg.audio = None
150+
msg.voice = None
151+
msg.video_note = None
152+
153+
assert has_media(msg) is True
154+
155+
def test_video_detected(self):
156+
"""Test that message with video is detected."""
157+
msg = MagicMock(spec=Message)
158+
msg.photo = None
159+
msg.video = MagicMock()
160+
msg.animation = None
161+
msg.document = None
162+
msg.audio = None
163+
msg.voice = None
164+
msg.video_note = None
165+
166+
assert has_media(msg) is True
167+
168+
def test_animation_detected(self):
169+
"""Test that message with animation is detected."""
170+
msg = MagicMock(spec=Message)
171+
msg.photo = None
172+
msg.video = None
173+
msg.animation = MagicMock()
174+
msg.document = None
175+
msg.audio = None
176+
msg.voice = None
177+
msg.video_note = None
178+
179+
assert has_media(msg) is True
180+
181+
def test_audio_detected(self):
182+
"""Test that message with audio is detected."""
183+
msg = MagicMock(spec=Message)
184+
msg.photo = None
185+
msg.video = None
186+
msg.animation = None
187+
msg.document = None
188+
msg.audio = MagicMock()
189+
msg.voice = None
190+
msg.video_note = None
191+
192+
assert has_media(msg) is True
193+
194+
def test_voice_detected(self):
195+
"""Test that message with voice is detected."""
196+
msg = MagicMock(spec=Message)
197+
msg.photo = None
198+
msg.video = None
199+
msg.animation = None
200+
msg.document = None
201+
msg.audio = None
202+
msg.voice = MagicMock()
203+
msg.video_note = None
204+
205+
assert has_media(msg) is True
206+
207+
def test_video_note_detected(self):
208+
"""Test that message with video_note is detected."""
209+
msg = MagicMock(spec=Message)
210+
msg.photo = None
211+
msg.video = None
212+
msg.animation = None
213+
msg.document = None
214+
msg.audio = None
215+
msg.voice = None
216+
msg.video_note = MagicMock()
217+
218+
assert has_media(msg) is True
219+
220+
def test_no_media_returns_false(self):
221+
"""Test that message without media returns False."""
222+
msg = MagicMock(spec=Message)
223+
msg.photo = None
224+
msg.video = None
225+
msg.animation = None
226+
msg.document = None
227+
msg.audio = None
228+
msg.voice = None
229+
msg.video_note = None
230+
231+
assert has_media(msg) is False
232+
233+
138234
class TestUrlWhitelist:
139235
"""Tests for URL whitelist functionality."""
140236

@@ -305,14 +401,21 @@ def mock_update(self):
305401
update.effective_chat = MagicMock(spec=Chat)
306402
update.effective_chat.id = -100123456 # group_id from group_config
307403

308-
# Default: not forwarded, no links, no external reply, no story
404+
# Default: not forwarded, no links, no external reply, no story, no media
309405
update.message.forward_origin = None
310406
update.message.external_reply = None
311407
update.message.story = None
312408
update.message.entities = None
313409
update.message.caption_entities = None
314410
update.message.text = None
315411
update.message.caption = None
412+
update.message.photo = None
413+
update.message.video = None
414+
update.message.animation = None
415+
update.message.document = None
416+
update.message.audio = None
417+
update.message.voice = None
418+
update.message.video_note = None
316419
update.message.delete = AsyncMock()
317420

318421
return update
@@ -687,6 +790,162 @@ async def test_deletes_message_with_story(
687790

688791
mock_update.message.delete.assert_called_once()
689792

793+
@pytest.mark.asyncio
794+
async def test_deletes_message_with_media(
795+
self, mock_update, mock_context, group_config
796+
):
797+
"""Test that messages with media attachments are deleted."""
798+
mock_update.message.photo = [MagicMock()] # Any non-None value
799+
800+
mock_record = MagicMock()
801+
mock_record.joined_at = datetime.now(UTC)
802+
803+
updated_record = MagicMock()
804+
updated_record.violation_count = 1
805+
806+
mock_db = MagicMock()
807+
mock_db.get_new_user_probation.return_value = mock_record
808+
mock_db.increment_new_user_violation.return_value = updated_record
809+
810+
with (
811+
patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config),
812+
patch("bot.handlers.anti_spam.get_database", return_value=mock_db),
813+
):
814+
with pytest.raises(ApplicationHandlerStop):
815+
await handle_new_user_spam(mock_update, mock_context)
816+
817+
mock_update.message.delete.assert_called_once()
818+
819+
@pytest.mark.asyncio
820+
async def test_deletes_message_with_video(
821+
self, mock_update, mock_context, group_config
822+
):
823+
"""Test that messages with video are deleted."""
824+
mock_update.message.video = MagicMock()
825+
826+
mock_record = MagicMock()
827+
mock_record.joined_at = datetime.now(UTC)
828+
829+
updated_record = MagicMock()
830+
updated_record.violation_count = 1
831+
832+
mock_db = MagicMock()
833+
mock_db.get_new_user_probation.return_value = mock_record
834+
mock_db.increment_new_user_violation.return_value = updated_record
835+
836+
with (
837+
patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config),
838+
patch("bot.handlers.anti_spam.get_database", return_value=mock_db),
839+
):
840+
with pytest.raises(ApplicationHandlerStop):
841+
await handle_new_user_spam(mock_update, mock_context)
842+
843+
mock_update.message.delete.assert_called_once()
844+
845+
@pytest.mark.asyncio
846+
async def test_deletes_message_with_animation(
847+
self, mock_update, mock_context, group_config
848+
):
849+
"""Test that messages with animation are deleted."""
850+
mock_update.message.animation = MagicMock()
851+
852+
mock_record = MagicMock()
853+
mock_record.joined_at = datetime.now(UTC)
854+
855+
updated_record = MagicMock()
856+
updated_record.violation_count = 1
857+
858+
mock_db = MagicMock()
859+
mock_db.get_new_user_probation.return_value = mock_record
860+
mock_db.increment_new_user_violation.return_value = updated_record
861+
862+
with (
863+
patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config),
864+
patch("bot.handlers.anti_spam.get_database", return_value=mock_db),
865+
):
866+
with pytest.raises(ApplicationHandlerStop):
867+
await handle_new_user_spam(mock_update, mock_context)
868+
869+
mock_update.message.delete.assert_called_once()
870+
871+
@pytest.mark.asyncio
872+
async def test_deletes_message_with_audio(
873+
self, mock_update, mock_context, group_config
874+
):
875+
"""Test that messages with audio are deleted."""
876+
mock_update.message.audio = MagicMock()
877+
878+
mock_record = MagicMock()
879+
mock_record.joined_at = datetime.now(UTC)
880+
881+
updated_record = MagicMock()
882+
updated_record.violation_count = 1
883+
884+
mock_db = MagicMock()
885+
mock_db.get_new_user_probation.return_value = mock_record
886+
mock_db.increment_new_user_violation.return_value = updated_record
887+
888+
with (
889+
patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config),
890+
patch("bot.handlers.anti_spam.get_database", return_value=mock_db),
891+
):
892+
with pytest.raises(ApplicationHandlerStop):
893+
await handle_new_user_spam(mock_update, mock_context)
894+
895+
mock_update.message.delete.assert_called_once()
896+
897+
@pytest.mark.asyncio
898+
async def test_deletes_message_with_voice(
899+
self, mock_update, mock_context, group_config
900+
):
901+
"""Test that messages with voice are deleted."""
902+
mock_update.message.voice = MagicMock()
903+
904+
mock_record = MagicMock()
905+
mock_record.joined_at = datetime.now(UTC)
906+
907+
updated_record = MagicMock()
908+
updated_record.violation_count = 1
909+
910+
mock_db = MagicMock()
911+
mock_db.get_new_user_probation.return_value = mock_record
912+
mock_db.increment_new_user_violation.return_value = updated_record
913+
914+
with (
915+
patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config),
916+
patch("bot.handlers.anti_spam.get_database", return_value=mock_db),
917+
):
918+
with pytest.raises(ApplicationHandlerStop):
919+
await handle_new_user_spam(mock_update, mock_context)
920+
921+
mock_update.message.delete.assert_called_once()
922+
923+
@pytest.mark.asyncio
924+
async def test_deletes_message_with_video_note(
925+
self, mock_update, mock_context, group_config
926+
):
927+
"""Test that messages with video_note are deleted."""
928+
mock_update.message.video_note = MagicMock()
929+
930+
mock_record = MagicMock()
931+
mock_record.joined_at = datetime.now(UTC)
932+
933+
updated_record = MagicMock()
934+
updated_record.violation_count = 1
935+
936+
mock_db = MagicMock()
937+
mock_db.get_new_user_probation.return_value = mock_record
938+
mock_db.increment_new_user_violation.return_value = updated_record
939+
940+
with (
941+
patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config),
942+
patch("bot.handlers.anti_spam.get_database", return_value=mock_db),
943+
):
944+
with pytest.raises(ApplicationHandlerStop):
945+
await handle_new_user_spam(mock_update, mock_context)
946+
947+
mock_update.message.delete.assert_called_once()
948+
690949
@pytest.mark.asyncio
691950
async def test_ignores_update_without_message(self, mock_context):
692951
"""Test that update without message is ignored."""

0 commit comments

Comments
 (0)