From c63858e217000c0874cfa736048ad55fff0df403 Mon Sep 17 00:00:00 2001 From: Tayo Date: Fri, 9 Oct 2026 11:28:15 +0700 Subject: [PATCH 1/2] feat(ai-spam): classify media captions and log alert delivery The AI spam monitor only inspected message.text, so spam sent as a photo/video with the payload in the caption never reached the classifier. Match and classify message.text or message.caption. Also add logging around alert delivery: warn when no alert chat is configured, log the target chat id before/after sending, and include the chat id on send failures, so a misconfigured ai_spam_alert_chat_id is visible in Logfire instead of failing silently. --- src/bot/handlers/ai_spam_monitor.py | 42 +++++++++++++++++++++++------ tests/test_ai_spam_monitor.py | 37 +++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/bot/handlers/ai_spam_monitor.py b/src/bot/handlers/ai_spam_monitor.py index 3c09a36..e21c381 100644 --- a/src/bot/handlers/ai_spam_monitor.py +++ b/src/bot/handlers/ai_spam_monitor.py @@ -18,7 +18,7 @@ import logging import time -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Update, User +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update, User from bot.dispatch import ( HandlerContext, @@ -27,7 +27,6 @@ effective_chat, effective_message, effective_user, - has_text, is_command_message, is_group_chat, ) @@ -66,9 +65,17 @@ logger = logging.getLogger(__name__) +def _classifiable_text(message: Message) -> str: + """Return the text available for classification: body text or media caption.""" + return message.text or message.caption or "" + + def ai_spam_filter(update: Update) -> bool: - """Match group text messages that are not commands.""" - return is_group_chat(update) and has_text(update) and not is_command_message(update) + """Match group text/caption messages that are not commands.""" + if not is_group_chat(update) or is_command_message(update): + return False + message = effective_message(update) + return message is not None and bool(_classifiable_text(message).strip()) AI_SPAM_FILTER = ai_spam_filter @@ -241,8 +248,16 @@ async def _classify_and_alert( group_config = _get_group_config(context, group_id) alert_chat_id = group_config.ai_spam_alert_chat_id if group_config else None if alert_chat_id is None: + logger.warning( + f"ai_spam_monitor: no alert chat configured for group={group_id}; " + f"dropping alert user_id={user.id} message_id={message_id} flags={flags_display}" + ) return if _already_alerted(context, (group_id, message_id)): + logger.info( + f"ai_spam_monitor: alert already sent for group={group_id} " + f"message_id={message_id}; skipping duplicate" + ) return profile_status = await _fetch_profile_status(context, user) @@ -255,6 +270,10 @@ async def _classify_and_alert( profile_status=profile_status, message_text=truncate_alert_text(message_text), ) + logger.info( + f"ai_spam_monitor: sending alert to chat_id={alert_chat_id} " + f"for group={group_id} user_id={user.id} message_id={message_id} flags={flags_display}" + ) try: await context.bot.send_message( chat_id=alert_chat_id, @@ -263,9 +282,15 @@ async def _classify_and_alert( ) except Exception: logger.error( - f"ai_spam_monitor: failed to send alert for user_id={user.id}", + f"ai_spam_monitor: failed to send alert to chat_id={alert_chat_id} " + f"for user_id={user.id} message_id={message_id}", exc_info=True, ) + else: + logger.info( + f"ai_spam_monitor: alert sent to chat_id={alert_chat_id} " + f"for group={group_id} message_id={message_id}" + ) async def handle_ai_spam_monitor( @@ -274,9 +299,10 @@ async def handle_ai_spam_monitor( """Last-defense entry handler: spawn a background classification.""" message = effective_message(update) user = effective_user(update) - if message is None or message.text is None or user is None or user.is_bot: + if message is None or user is None or user.is_bot: return - if len(message.text.strip()) < AI_SPAM_MIN_LENGTH: + classifiable = _classifiable_text(message) + if len(classifiable.strip()) < AI_SPAM_MIN_LENGTH: return chat = effective_chat(update) @@ -299,7 +325,7 @@ async def handle_ai_spam_monitor( group_id=group_id, user=user, message_id=message.message_id, - message_text=message.text, + message_text=classifiable, ), ) diff --git a/tests/test_ai_spam_monitor.py b/tests/test_ai_spam_monitor.py index 92c3052..8b3331d 100644 --- a/tests/test_ai_spam_monitor.py +++ b/tests/test_ai_spam_monitor.py @@ -17,6 +17,7 @@ ACTION_DELETE_RESTRICT, ACTION_DISMISS, ALERTS_KEY, + ai_spam_filter, flagged_aspects, handle_ai_spam_action, handle_ai_spam_monitor, @@ -102,10 +103,12 @@ def make_update( user_id: int = 42, is_bot: bool = False, message_id: int = 100, + caption: str | None = None, ) -> MagicMock: update = MagicMock() message = MagicMock() message.text = text + message.caption = caption message.message_id = message_id chat = MagicMock() chat.id = GROUP_ID @@ -170,9 +173,23 @@ async def test_skips_non_admin_short_text(self, mock_settings, context): context.create_task.assert_not_called() @patch("bot.handlers.ai_spam_monitor.get_settings") - async def test_skips_media_message(self, mock_settings, context): + async def test_skips_media_message_without_caption(self, mock_settings, context): mock_settings.return_value = make_settings() - update = make_update(text=None) + update = make_update(text=None, caption=None) + await handle_ai_spam_monitor(update, context) + context.create_task.assert_not_called() + + @patch("bot.handlers.ai_spam_monitor.get_settings") + async def test_spawns_task_for_caption_message(self, mock_settings, context): + mock_settings.return_value = make_settings() + update = make_update(text=None, caption=LONG_TEXT) + await handle_ai_spam_monitor(update, context) + context.create_task.assert_called_once() + + @patch("bot.handlers.ai_spam_monitor.get_settings") + async def test_skips_short_caption(self, mock_settings, context): + mock_settings.return_value = make_settings() + update = make_update(text=None, caption="ok siap") await handle_ai_spam_monitor(update, context) context.create_task.assert_not_called() @@ -222,6 +239,22 @@ async def test_skips_trusted_user(self, mock_settings, context): context.create_task.assert_not_called() +class TestAiSpamFilter: + """Tests for the group-7 filter: text or caption, never commands.""" + + def test_matches_text_message(self): + assert ai_spam_filter(make_update(text=LONG_TEXT)) is True + + def test_matches_caption_message(self): + assert ai_spam_filter(make_update(text=None, caption=LONG_TEXT)) is True + + def test_rejects_media_without_caption(self): + assert ai_spam_filter(make_update(text=None, caption=None)) is False + + def test_rejects_blank_text_and_caption(self): + assert ai_spam_filter(make_update(text=" ", caption=" ")) is False + + class TestClassifyAndAlert: """Tests for the background classification + alert coroutine.""" From 6294bfe8555e71590ca939d682e47bcbaeddcc09 Mon Sep 17 00:00:00 2001 From: Tayo Date: Fri, 9 Oct 2026 11:35:17 +0700 Subject: [PATCH 2/2] feat: /testalert DM command to verify AI-spam alert delivery Admin-only DM command: sends a clearly-marked test message to the configured ai_spam_alert_chat_id of the caller's admin groups (or a single group via /testalert ) and reports per-group success/failure back in the DM, including the chat id used and the error on failure. Lets an admin confirm the alert chat is correct and reachable without waiting for real spam. --- src/bot/handlers/testalert.py | 145 +++++++++++++++++++++++ src/bot/plugins/builtin/testalert.py | 23 ++++ src/bot/plugins/definitions.py | 1 + src/bot/plugins/manager.py | 3 + tests/test_plugin_manager.py | 3 + tests/test_testalert.py | 164 +++++++++++++++++++++++++++ 6 files changed, 339 insertions(+) create mode 100644 src/bot/handlers/testalert.py create mode 100644 src/bot/plugins/builtin/testalert.py create mode 100644 tests/test_testalert.py diff --git a/src/bot/handlers/testalert.py b/src/bot/handlers/testalert.py new file mode 100644 index 0000000..f646475 --- /dev/null +++ b/src/bot/handlers/testalert.py @@ -0,0 +1,145 @@ +"""DM admin command: /testalert โ€” verify AI-spam alert delivery. + +Sends a clearly-marked test message to the configured +``ai_spam_alert_chat_id`` of the caller's admin groups (or one group +given as an argument), then reports per-group success/failure back in +the DM. Lets an admin confirm the alert chat id is correct and the bot +can actually send there, without waiting for real spam. +""" + +from __future__ import annotations + +import logging + +from bot.dispatch import ( + AppState, + HandlerContext, + command_filter, + effective_chat, + HandlerSpec, +) +from bot.group_config import get_group_registry +from bot.services.telegram_utils import get_admin_groups + +logger = logging.getLogger(__name__) + +TEST_ALERT_TEXT = ( + "๐Ÿงช *Test Alert* โ€” pesan uji dari /testalert.\n\n" + "Kalau kamu membaca ini, `ai_spam_alert_chat_id` untuk grup `{group_id}` " + "sudah benar dan bot bisa mengirim pesan ke sini." +) + + +async def _check_testalert_prereqs(update, context: HandlerContext) -> bool: + """Validate /testalert prerequisites: message exists, private chat, admin.""" + if not update.message or not update.message.from_user: + logger.warning("handle_testalert called without message or sender") + return False + + chat = effective_chat(update) + if chat and chat.type != "private": + await update.message.reply( + "โŒ Perintah ini hanya bisa digunakan di chat pribadi dengan bot." + ) + return False + + admin_user_id = update.message.from_user.id + if admin_user_id not in context.state.admin_ids: + await update.message.reply( + "โŒ Kamu tidak memiliki izin untuk menggunakan perintah ini." + ) + logger.warning( + "Non-admin user %s attempted /testalert", admin_user_id + ) + return False + return True + + +async def handle_testalert_command(update, context: HandlerContext) -> None: + """Handle /testalert [group_id] in bot DM โ€” test AI-spam alert delivery.""" + if not await _check_testalert_prereqs(update, context): + return + + admin_user_id = update.message.from_user.id + admin_group_ids = set(get_admin_groups(context, admin_user_id)) + + args = context.args or [] + if args: + try: + wanted = int(args[0]) + except (ValueError, TypeError): + await update.message.reply( + "โŒ Format: `/testalert [group_id]` โ€” contoh: `/testalert -1001052242766`." + ) + return + if wanted not in admin_group_ids: + await update.message.reply( + "โŒ Kamu bukan admin grup itu atau grup tidak dikenal." + ) + return + target_groups = [wanted] + else: + target_groups = sorted(admin_group_ids) + + if not target_groups: + await update.message.reply("โŒ Kamu tidak menjadi admin di grup mana pun.") + return + + registry = get_group_registry() + lines: list[str] = [] + for group_id in target_groups: + group_config = registry.get(group_id) + alert_chat_id = ( + group_config.ai_spam_alert_chat_id if group_config else None + ) + if alert_chat_id is None: + lines.append( + f"โŒ `{group_id}`: `ai_spam_alert_chat_id` belum dikonfigurasi." + ) + logger.warning( + "testalert: no alert chat configured for group=%s", group_id + ) + continue + try: + await context.bot.send_message( + chat_id=alert_chat_id, + text=TEST_ALERT_TEXT.format(group_id=group_id), + parse_mode="Markdown", + ) + except Exception as e: + lines.append( + f"โŒ `{group_id}`: gagal kirim ke `{alert_chat_id}`: {e}" + ) + logger.error( + "testalert: failed to send test alert to chat_id=%s " + "for group=%s", + alert_chat_id, + group_id, + exc_info=True, + ) + else: + lines.append( + f"โœ… `{group_id}`: terkirim ke `{alert_chat_id}`." + ) + logger.info( + "testalert: test alert sent to chat_id=%s for group=%s", + alert_chat_id, + group_id, + ) + + await update.message.reply("\n".join(lines), parse_mode="Markdown") + + +def get_handlers(state: AppState) -> list[HandlerSpec]: + """Return list of handler specs for the testalert command.""" + return [ + HandlerSpec( + plugin_name="testalert", + group=0, + update_kinds=("message", "edited_message"), + check=command_filter("testalert", state), + callback=handle_testalert_command, + label="testalert_command", + command="testalert", + ) + ] diff --git a/src/bot/plugins/builtin/testalert.py b/src/bot/plugins/builtin/testalert.py new file mode 100644 index 0000000..f54bf54 --- /dev/null +++ b/src/bot/plugins/builtin/testalert.py @@ -0,0 +1,23 @@ +"""Built-in plugin: testalert. + +Wraps ``bot.handlers.testalert`` for the ``/testalert`` DM-admin command. +No ``guard_plugin`` wrap โ€” /testalert is admin-only by handler checks, not +per-group gated. +""" + +from __future__ import annotations + +import logging + +from bot.dispatch import AppState, HandlerSpec +from bot.handlers import testalert + +logger = logging.getLogger(__name__) + + +def register_testalert(state: AppState) -> list[HandlerSpec]: + """Register /testalert command handler spec.""" + specs = testalert.get_handlers(state) + # No guard_plugin wrap โ€” /testalert is admin-gated by the handler itself + logger.info("Registered handler: testalert (group=0)") + return specs diff --git a/src/bot/plugins/definitions.py b/src/bot/plugins/definitions.py index d5cd421..db6b046 100644 --- a/src/bot/plugins/definitions.py +++ b/src/bot/plugins/definitions.py @@ -36,6 +36,7 @@ {"name": "captcha", "handler_group": 0, "description": "Captcha verification for new members"}, {"name": "dm", "handler_group": 0, "description": "Direct message unrestriction flow"}, {"name": "status", "handler_group": 0, "description": "Admin /status command"}, + {"name": "testalert", "handler_group": 0, "description": "Admin /testalert DM command (AI-spam alert delivery test)"}, {"name": "guest_bot_block", "handler_group": 0, "description": "Block non-whitelisted guest bot messages"}, {"name": "ai_spam_callback", "handler_group": 0, "description": "Admin action buttons on AI spam monitor alerts"}, {"name": "inline_keyboard_spam", "handler_group": 1, "description": "Block inline keyboard URL spam"}, diff --git a/src/bot/plugins/manager.py b/src/bot/plugins/manager.py index 18a385d..c6869fc 100644 --- a/src/bot/plugins/manager.py +++ b/src/bot/plugins/manager.py @@ -34,6 +34,7 @@ from bot.plugins.builtin import profile_monitor as pm_mod from bot.plugins.builtin import spam as spam_mod from bot.plugins.builtin import status as status_mod +from bot.plugins.builtin import testalert as testalert_mod from bot.plugins.builtin import topic_guard as tg_mod from bot.plugins.config import resolve_plugin_toggles from bot.plugins.definitions import MANIFEST_ORDER, get_plugin_definitions @@ -71,6 +72,8 @@ "dm": dm_mod.register_dm, # status "status": status_mod.register_status, + # testalert + "testalert": testalert_mod.register_testalert, # spam "inline_keyboard_spam": spam_mod.register_inline_keyboard_spam, "guest_bot_block": spam_mod.register_guest_bot_block, diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index c7fbbb9..307b52d 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -151,6 +151,7 @@ def _expected_order() -> tuple[str, ...]: "captcha", "dm", "status", + "testalert", "guest_bot_block", "ai_spam_callback", "inline_keyboard_spam", @@ -685,6 +686,7 @@ def test_registered_handler_groups_match_definitions(self): profile_monitor as pm_mod, spam as spam_mod, status as status_mod, + testalert as testalert_mod, topic_guard as tg_mod, ) from bot.plugins.manager import _REGISTRY @@ -711,6 +713,7 @@ def test_registered_handler_groups_match_definitions(self): "captcha": captcha_mod.register_captcha, "dm": dm_mod.register_dm, "status": status_mod.register_status, + "testalert": testalert_mod.register_testalert, "inline_keyboard_spam": spam_mod.register_inline_keyboard_spam, "guest_bot_block": spam_mod.register_guest_bot_block, "contact_spam": spam_mod.register_contact_spam, diff --git a/tests/test_testalert.py b/tests/test_testalert.py new file mode 100644 index 0000000..241f416 --- /dev/null +++ b/tests/test_testalert.py @@ -0,0 +1,164 @@ +"""Tests for the /testalert DM admin command.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from bot.dispatch import AppState, HandlerContext +from bot.handlers.testalert import get_handlers, handle_testalert_command + +GROUP_ID = -1001052242766 +ALERT_CHAT_ID = -100999888777 + + +def make_update(chat_type="private", user_id=1, args_text="/testalert"): + update = MagicMock() + message = MagicMock() + message.text = args_text + message.reply = AsyncMock() + chat = MagicMock() + chat.id = 111 + chat.type = chat_type + message.chat = chat + user = MagicMock() + user.id = user_id + message.from_user = user + update.message = message + update.edited_message = None + update.callback_query = None + update.chat_member = None + return update + + +def make_context(admin_ids=(1,)): + bot = MagicMock() + bot.send_message = AsyncMock() + state = AppState(bot=bot) + state.admin_ids = set(admin_ids) + context = HandlerContext(bot=bot, state=state) + context.args = [] + return context + + +def make_group_config(alert_chat_id=ALERT_CHAT_ID): + config = MagicMock() + config.ai_spam_alert_chat_id = alert_chat_id + return config + + +class TestHandleTestalertCommand: + @patch("bot.handlers.testalert.get_admin_groups") + @patch("bot.handlers.testalert.get_group_registry") + async def test_rejects_group_chat(self, mock_registry, mock_admin_groups): + update = make_update(chat_type="supergroup") + context = make_context() + await handle_testalert_command(update, context) + update.message.reply.assert_called_once() + assert "pribadi" in update.message.reply.call_args.args[0] + context.bot.send_message.assert_not_called() + + @patch("bot.handlers.testalert.get_admin_groups") + @patch("bot.handlers.testalert.get_group_registry") + async def test_rejects_non_admin(self, mock_registry, mock_admin_groups): + update = make_update(user_id=99) + context = make_context() + await handle_testalert_command(update, context) + update.message.reply.assert_called_once() + assert "izin" in update.message.reply.call_args.args[0] + context.bot.send_message.assert_not_called() + + @patch("bot.handlers.testalert.get_admin_groups") + @patch("bot.handlers.testalert.get_group_registry") + async def test_reports_unconfigured_alert_chat( + self, mock_registry, mock_admin_groups + ): + mock_admin_groups.return_value = [GROUP_ID] + mock_registry.return_value.get.return_value = make_group_config( + alert_chat_id=None + ) + update = make_update() + context = make_context() + await handle_testalert_command(update, context) + context.bot.send_message.assert_not_called() + reply_text = update.message.reply.call_args.args[0] + assert "belum dikonfigurasi" in reply_text + + @patch("bot.handlers.testalert.get_admin_groups") + @patch("bot.handlers.testalert.get_group_registry") + async def test_sends_test_alert_on_success( + self, mock_registry, mock_admin_groups + ): + mock_admin_groups.return_value = [GROUP_ID] + mock_registry.return_value.get.return_value = make_group_config() + update = make_update() + context = make_context() + await handle_testalert_command(update, context) + context.bot.send_message.assert_called_once() + call_kwargs = context.bot.send_message.call_args.kwargs + assert call_kwargs["chat_id"] == ALERT_CHAT_ID + assert "Test Alert" in call_kwargs["text"] + reply_text = update.message.reply.call_args.args[0] + assert "โœ…" in reply_text + assert str(ALERT_CHAT_ID) in reply_text + + @patch("bot.handlers.testalert.get_admin_groups") + @patch("bot.handlers.testalert.get_group_registry") + async def test_reports_send_failure(self, mock_registry, mock_admin_groups): + mock_admin_groups.return_value = [GROUP_ID] + mock_registry.return_value.get.return_value = make_group_config() + update = make_update() + context = make_context() + context.bot.send_message.side_effect = RuntimeError("chat not found") + await handle_testalert_command(update, context) + reply_text = update.message.reply.call_args.args[0] + assert "โŒ" in reply_text + assert "chat not found" in reply_text + + @patch("bot.handlers.testalert.get_admin_groups") + @patch("bot.handlers.testalert.get_group_registry") + async def test_group_id_arg_scopes_to_one_group( + self, mock_registry, mock_admin_groups + ): + mock_admin_groups.return_value = [GROUP_ID, -200] + mock_registry.return_value.get.return_value = make_group_config() + update = make_update(args_text=f"/testalert {GROUP_ID}") + context = make_context() + context.args = [str(GROUP_ID)] + await handle_testalert_command(update, context) + assert context.bot.send_message.call_count == 1 + mock_registry.return_value.get.assert_called_once_with(GROUP_ID) + + @patch("bot.handlers.testalert.get_admin_groups") + @patch("bot.handlers.testalert.get_group_registry") + async def test_rejects_group_id_not_administered( + self, mock_registry, mock_admin_groups + ): + mock_admin_groups.return_value = [GROUP_ID] + update = make_update(args_text="/testalert -999") + context = make_context() + context.args = ["-999"] + await handle_testalert_command(update, context) + context.bot.send_message.assert_not_called() + assert "bukan admin" in update.message.reply.call_args.args[0] + + @patch("bot.handlers.testalert.get_admin_groups") + @patch("bot.handlers.testalert.get_group_registry") + async def test_rejects_invalid_arg(self, mock_registry, mock_admin_groups): + mock_admin_groups.return_value = [GROUP_ID] + update = make_update(args_text="/testalert abc") + context = make_context() + context.args = ["abc"] + await handle_testalert_command(update, context) + context.bot.send_message.assert_not_called() + assert "Format" in update.message.reply.call_args.args[0] + + +class TestGetHandlers: + def test_returns_command_spec(self): + state = AppState(bot=MagicMock()) + state.bot_username = "testbot" + specs = get_handlers(state) + assert len(specs) == 1 + spec = specs[0] + assert spec.plugin_name == "testalert" + assert spec.label == "testalert_command" + assert spec.command == "testalert" + assert spec.group == 0