Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions src/bot/handlers/ai_spam_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,7 +27,6 @@
effective_chat,
effective_message,
effective_user,
has_text,
is_command_message,
is_group_chat,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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,
),
)

Expand Down
145 changes: 145 additions & 0 deletions src/bot/handlers/testalert.py
Original file line number Diff line number Diff line change
@@ -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",
)
]
23 changes: 23 additions & 0 deletions src/bot/plugins/builtin/testalert.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/bot/plugins/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
3 changes: 3 additions & 0 deletions src/bot/plugins/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 35 additions & 2 deletions tests/test_ai_spam_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ACTION_DELETE_RESTRICT,
ACTION_DISMISS,
ALERTS_KEY,
ai_spam_filter,
flagged_aspects,
handle_ai_spam_action,
handle_ai_spam_monitor,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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."""

Expand Down
3 changes: 3 additions & 0 deletions tests/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def _expected_order() -> tuple[str, ...]:
"captcha",
"dm",
"status",
"testalert",
"guest_bot_block",
"ai_spam_callback",
"inline_keyboard_spam",
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading
Loading