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/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_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.""" 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