From ddd48eec8751c80b2ff695793f53e704a470855a Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Sat, 1 Aug 2026 12:46:56 +0700 Subject: [PATCH 1/2] feat: add /warn command for admin-issued member warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a generic /warn command that lets admins make the bot send a warning to a group member instead of typing it themselves. Supports two invocation modes: reply to the member's message (/warn [reason]) or by user ID (/warn USER_ID [reason]). Key design decisions: - Per-group admin authorization via is_user_admin_in_group (not global union) so admins of one group cannot warn in another - Admin's command message is deleted early to protect their identity on all code paths, with do_quote=False on error replies - ID mode uses get_chat_member to verify group membership and reject left/banned users; ChatMember.user provides the User object - Reasons are Markdown-escaped to prevent BadRequest from metacharacters in admin-supplied text - Warnings route to a separate moderation_topic_id (optional per-group config, defaults to None) distinct from the existing warning_topic_id used for bot logging - Non-admin callers are silently ignored (no reply, no error) - Cannot warn bots or yourself (silent ignore) - No DB records — purely a warning message, not progressive enforcement Wired into the plugin system as warn_command (handler_group=0), not gated by guard_plugin per project convention for admin commands. --- .env.example | 5 + groups.json.example | 2 + src/bot/config.py | 2 + src/bot/constants.py | 15 + src/bot/group_config.py | 1 + src/bot/handlers/warn.py | 170 ++++++++++ src/bot/plugins/builtin/commands.py | 7 + src/bot/plugins/definitions.py | 1 + src/bot/plugins/manager.py | 1 + tests/test_plugin_manager.py | 1 + tests/test_warn.py | 466 ++++++++++++++++++++++++++++ 11 files changed, 671 insertions(+) create mode 100644 src/bot/handlers/warn.py create mode 100644 tests/test_warn.py diff --git a/.env.example b/.env.example index 20dbcb0..5fcc6a2 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,11 @@ BIO_BAIT_MONITOR_ONLY=false # Example: 57747812 # BIO_BAIT_ALERT_CHAT_ID=57747812 +# Topic ID for admin-issued /warn moderation messages (optional) +# When set, /warn sends warnings to this topic instead of the main group chat. +# Example: 456 +# MODERATION_TOPIC_ID=456 + # Path to groups.json for multi-group support (optional) # If this file exists, per-group settings are loaded from it instead of the # GROUP_ID/WARNING_TOPIC_ID/etc. fields above. See groups.json.example. diff --git a/groups.json.example b/groups.json.example index 048c68c..1cefc4c 100644 --- a/groups.json.example +++ b/groups.json.example @@ -19,6 +19,7 @@ "bio_bait_enabled": true, "bio_bait_monitor_only": false, "bio_bait_alert_chat_id": null, + "moderation_topic_id": null, "plugins": { "captcha": false, "dm": true, @@ -45,6 +46,7 @@ "bio_bait_enabled": true, "bio_bait_monitor_only": false, "bio_bait_alert_chat_id": null, + "moderation_topic_id": null, "plugins": { "contact_spam": false, "duplicate_spam": false, diff --git a/src/bot/config.py b/src/bot/config.py index f7454f8..adc0e3b 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -83,6 +83,7 @@ class Settings(BaseSettings): bio_bait_enabled: bool = True bio_bait_monitor_only: bool = False bio_bait_alert_chat_id: int | None = None + moderation_topic_id: int | None = None groups_config_path: str = "groups.json" logfire_token: str | None = None logfire_service_name: str = "pythonid-bot" @@ -152,6 +153,7 @@ def model_post_init(self, __context): "bio_bait_enabled", "bio_bait_monitor_only", "bio_bait_alert_chat_id", + "moderation_topic_id", ): logger.debug(f"{field}: {getattr(self, field)}") logger.debug(f"telegram_bot_token: {'***' + self.telegram_bot_token[-4:]}") diff --git a/src/bot/constants.py b/src/bot/constants.py index a9ecf3d..24e7a96 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -232,6 +232,21 @@ def format_hours_display(hours: int) -> str: ADMIN_WARN_SENT_MESSAGE = "✅ Peringatan telah dikirim ke {user_mention} di grup." +# Generic /warn command templates (admin-issued, in-group) +WARN_COMMAND_USAGE = ( + "❌ Penggunaan: balas pesan member dengan /warn [alasan] " + "atau gunakan /warn USER_ID [alasan]" +) +WARN_COMMAND_NOT_FOUND = "❌ Tidak dapat menemukan user dengan ID {user_id}." +WARN_COMMAND_NOT_MEMBER = "❌ User {user_id} bukan member grup ini." +WARN_COMMAND_WITH_REASON = ( + "⚠️ {user_mention}, kamu telah diperingatkan oleh admin: {reason}" +) +WARN_COMMAND_NO_REASON = ( + "⚠️ {user_mention}, kamu telah diperingatkan oleh admin. " + "Mohon patuhi aturan grup." +) + TRUST_USER_ID_REQUIRED_MESSAGE = ( "❌ Penggunaan: /trust USER_ID atau /untrust USER_ID, atau forward pesan user ke bot." ) diff --git a/src/bot/group_config.py b/src/bot/group_config.py index b321efc..6edb22d 100644 --- a/src/bot/group_config.py +++ b/src/bot/group_config.py @@ -43,6 +43,7 @@ class GroupConfig(BaseModel): bio_bait_enabled: bool = True bio_bait_monitor_only: bool = False bio_bait_alert_chat_id: int | None = None + moderation_topic_id: int | None = None plugins: dict[str, bool] | None = None @field_validator("group_id") diff --git a/src/bot/handlers/warn.py b/src/bot/handlers/warn.py new file mode 100644 index 0000000..2c2e5a1 --- /dev/null +++ b/src/bot/handlers/warn.py @@ -0,0 +1,170 @@ +""" +Admin /warn command handler for the PythonID bot. + +Lets an admin make the bot send a generic warning to a group member. +Two invocation modes: + +1. Reply mode: admin replies to the member's message with ``/warn [reason]`` +2. ID mode: admin sends ``/warn USER_ID [reason]`` in the group + +The warning is sent to the moderation topic when ``moderation_topic_id`` is +configured (per-group), otherwise to the main group chat. + +The admin's command message is deleted early to protect their identity. +Non-admin callers are silently ignored. +""" + +import logging + +from telegram import Update +from telegram.ext import ContextTypes +from telegram.helpers import escape_markdown + +from bot.constants import ( + WARN_COMMAND_NOT_FOUND, + WARN_COMMAND_NO_REASON, + WARN_COMMAND_NOT_MEMBER, + WARN_COMMAND_USAGE, + WARN_COMMAND_WITH_REASON, +) +from bot.group_config import get_group_config_for_update +from bot.services.telegram_utils import get_user_mention_by_id, is_user_admin_in_group + +logger = logging.getLogger(__name__) + + +async def _delete_command_message(update: Update) -> None: + """Best-effort delete the admin's /warn command message.""" + try: + await update.message.delete() # type: ignore[union-attr] + except Exception: + logger.warning( + "Failed to delete admin /warn command message", + exc_info=True, + ) + + +async def handle_warn_command( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """ + Handle /warn command in a monitored group. + + Admin replies to a member's message with ``/warn [reason]``, or + provides a user ID: ``/warn USER_ID [reason]``. + + Sends a warning message mentioning the target member. The admin's + command message is deleted before any network lookups to protect + their identity. + """ + if not update.message or not update.message.from_user: + return + + admin = update.message.from_user + message = update.message + + group_config = get_group_config_for_update(update) + if group_config is None: + return + + # Per-group admin check (not global union) + if not is_user_admin_in_group(context, group_config.group_id, admin.id): + return + + # Delete command message early to protect admin identity on all paths + await _delete_command_message(update) + + # Resolve target user and reason + reply_user = ( + message.reply_to_message.from_user + if message.reply_to_message + else None + ) + + if reply_user is not None: + target_user = reply_user + reason = " ".join(context.args) if context.args else "" + elif context.args: + try: + target_user_id = int(context.args[0]) + except ValueError: + try: + await message.reply_text(WARN_COMMAND_USAGE, do_quote=False) + except Exception: + logger.error("Failed to send usage message", exc_info=True) + return + try: + member = await context.bot.get_chat_member( + chat_id=group_config.group_id, + user_id=target_user_id, + ) + except Exception: + logger.error( + f"Failed to fetch member {target_user_id} for /warn", + exc_info=True, + ) + try: + await message.reply_text( + WARN_COMMAND_NOT_FOUND.format(user_id=target_user_id), + do_quote=False, + ) + except Exception: + logger.error("Failed to send error reply", exc_info=True) + return + if member.status in ("left", "kicked"): + try: + await message.reply_text( + WARN_COMMAND_NOT_MEMBER.format(user_id=target_user_id), + do_quote=False, + ) + except Exception: + logger.error("Failed to send not-member reply", exc_info=True) + return + target_user = member.user + if target_user is None: + return + reason = " ".join(context.args[1:]) if len(context.args) > 1 else "" + else: + try: + await message.reply_text(WARN_COMMAND_USAGE, do_quote=False) + except Exception: + logger.error("Failed to send usage message", exc_info=True) + return + + if target_user.is_bot: + return + + if target_user.id == admin.id: + return + + user_mention = get_user_mention_by_id( + target_user.id, target_user.full_name, getattr(target_user, "username", None) + ) + + if reason: + warn_text = WARN_COMMAND_WITH_REASON.format( + user_mention=user_mention, + reason=escape_markdown(reason, version=1), + ) + else: + warn_text = WARN_COMMAND_NO_REASON.format(user_mention=user_mention) + + try: + send_kwargs: dict[str, object] = { + "chat_id": group_config.group_id, + "text": warn_text, + "parse_mode": "Markdown", + } + if group_config.moderation_topic_id is not None: + send_kwargs["message_thread_id"] = group_config.moderation_topic_id + await context.bot.send_message(**send_kwargs) + except Exception: + logger.error( + f"Failed to send warning to group {group_config.group_id} for user {target_user.id}", + exc_info=True, + ) + return + + logger.info( + f"Admin {admin.id} warned user {target_user.id} in group {group_config.group_id}" + ) diff --git a/src/bot/plugins/builtin/commands.py b/src/bot/plugins/builtin/commands.py index b2f6ca7..18e9694 100644 --- a/src/bot/plugins/builtin/commands.py +++ b/src/bot/plugins/builtin/commands.py @@ -40,6 +40,7 @@ handle_verify_callback, handle_verify_command, ) +from bot.handlers.warn import handle_warn_command if TYPE_CHECKING: from telegram.ext import Application, BaseHandler @@ -164,3 +165,9 @@ def register_unrestrict_callback(application: Application) -> list[BaseHandler]: pattern=r"^unrestrict:-?\d+:\d+$", ) return _register(application, handler, "unrestrict_callback") + + +def register_warn_command(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register /warn command handler (in-group, admin-issued).""" + handler: BaseHandler = CommandHandler("warn", handle_warn_command) + return _register(application, handler, "warn_command") diff --git a/src/bot/plugins/definitions.py b/src/bot/plugins/definitions.py index 4b0bc5f..cc9617f 100644 --- a/src/bot/plugins/definitions.py +++ b/src/bot/plugins/definitions.py @@ -31,6 +31,7 @@ {"name": "trust_callback", "handler_group": 0, "description": "Admin trust (anti-spam exempt) button callback"}, {"name": "untrust_callback", "handler_group": 0, "description": "Admin untrust button callback"}, {"name": "unrestrict_callback", "handler_group": 0, "description": "Admin unrestrict (bot restriction only) button callback"}, + {"name": "warn_command", "handler_group": 0, "description": "Admin /warn command (in-group, bot-issued warning)"}, {"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"}, diff --git a/src/bot/plugins/manager.py b/src/bot/plugins/manager.py index 17574cf..9286d86 100644 --- a/src/bot/plugins/manager.py +++ b/src/bot/plugins/manager.py @@ -64,6 +64,7 @@ "trust_callback": commands.register_trust_callback, "untrust_callback": commands.register_untrust_callback, "unrestrict_callback": commands.register_unrestrict_callback, + "warn_command": commands.register_warn_command, # captcha "captcha": captcha_mod.register_captcha, # dm diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 9df3406..145c0bc 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -140,6 +140,7 @@ def _expected_order() -> tuple[str, ...]: "trust_callback", "untrust_callback", "unrestrict_callback", + "warn_command", "captcha", "dm", "status", diff --git a/tests/test_warn.py b/tests/test_warn.py new file mode 100644 index 0000000..05dbbb7 --- /dev/null +++ b/tests/test_warn.py @@ -0,0 +1,466 @@ +"""Tests for admin /warn command handler.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bot.group_config import GroupConfig, GroupRegistry +from bot.handlers.warn import handle_warn_command + + +@pytest.fixture +def group_config(): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=12345, + rules_link="https://t.me/test/rules", + ) + + +@pytest.fixture +def mock_registry(group_config): + registry = GroupRegistry() + registry.register(group_config) + return registry + + +@pytest.fixture +def mock_update(): + update = MagicMock() + update.message = MagicMock() + update.message.from_user = MagicMock() + update.message.from_user.id = 12345 + update.message.from_user.full_name = "Admin User" + update.message.from_user.is_bot = False + update.message.reply_text = AsyncMock() + update.message.delete = AsyncMock() + update.message.chat_id = -1001234567890 + update.message.message_id = 999 + update.message.reply_to_message = None + update.effective_chat = MagicMock() + update.effective_chat.id = -1001234567890 + update.effective_chat.type = "supergroup" + return update + + +@pytest.fixture +def mock_context(): + context = MagicMock() + context.bot = MagicMock() + context.bot.send_message = AsyncMock() + context.bot.get_chat_member = AsyncMock() + context.bot_data = { + "admin_ids": [12345], + "group_admin_ids": {-1001234567890: [12345]}, + } + context.args = [] + return context + + +def _make_target_user(user_id=67890, full_name="Bad Member", username="badmember"): + user = MagicMock() + user.id = user_id + user.full_name = full_name + user.username = username + user.is_bot = False + return user + + +def _make_reply_message(target_user): + msg = MagicMock() + msg.from_user = target_user + return msg + + +def _make_chat_member(user, status="member"): + member = MagicMock() + member.user = user + member.status = status + return member + + +class TestHandleWarnCommand: + async def test_non_admin_silent_ignore( + self, mock_update, mock_context, mock_registry + ): + """Non-admin callers are silently ignored.""" + mock_update.message.from_user.id = 99999 + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_not_called() + mock_update.message.reply_text.assert_not_called() + mock_update.message.delete.assert_not_called() + + async def test_admin_of_other_group_silent_ignore( + self, mock_update, mock_context, mock_registry + ): + """Admin of a different group is silently ignored in this group.""" + mock_context.bot_data = { + "admin_ids": [12345], + "group_admin_ids": { + -1001234567890: [], + -1009876543210: [12345], + }, + } + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_not_called() + mock_update.message.reply_text.assert_not_called() + mock_update.message.delete.assert_not_called() + + async def test_reply_mode_with_reason( + self, mock_update, mock_context, mock_registry + ): + """Admin replies to a member with /warn .""" + target = _make_target_user() + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = ["uploading", "copyrighted", "material"] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert call_kwargs["chat_id"] == -1001234567890 + assert "message_thread_id" not in call_kwargs + assert "badmember" in call_kwargs["text"] + assert "copyrighted" in call_kwargs["text"] + mock_update.message.delete.assert_called_once() + + async def test_reply_mode_without_reason( + self, mock_update, mock_context, mock_registry + ): + """Admin replies with /warn and no reason.""" + target = _make_target_user() + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = [] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert "patuhi aturan grup" in call_kwargs["text"] + mock_update.message.delete.assert_called_once() + + async def test_id_mode_with_reason( + self, mock_update, mock_context, mock_registry + ): + """Admin uses /warn USER_ID .""" + target = _make_target_user() + mock_context.bot.get_chat_member.return_value = _make_chat_member(target) + mock_context.args = ["67890", "spam", "links"] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.get_chat_member.assert_called_once_with( + chat_id=-1001234567890, user_id=67890 + ) + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert "spam links" in call_kwargs["text"] + mock_update.message.delete.assert_called_once() + + async def test_id_mode_without_reason( + self, mock_update, mock_context, mock_registry + ): + """Admin uses /warn USER_ID with no reason.""" + target = _make_target_user() + mock_context.bot.get_chat_member.return_value = _make_chat_member(target) + mock_context.args = ["67890"] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert "patuhi aturan grup" in call_kwargs["text"] + mock_update.message.delete.assert_called_once() + + async def test_id_mode_left_member_shows_error( + self, mock_update, mock_context, mock_registry + ): + """User who left the group cannot be warned.""" + target = _make_target_user() + mock_context.bot.get_chat_member.return_value = _make_chat_member(target, status="left") + mock_context.args = ["67890"] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_update.message.reply_text.assert_called_once() + reply_text = mock_update.message.reply_text.call_args + assert "bukan member" in reply_text.args[0] + assert reply_text.kwargs.get("do_quote") is False + mock_context.bot.send_message.assert_not_called() + + async def test_id_mode_banned_member_shows_error( + self, mock_update, mock_context, mock_registry + ): + """Banned user cannot be warned.""" + target = _make_target_user() + mock_context.bot.get_chat_member.return_value = _make_chat_member(target, status="kicked") + mock_context.args = ["67890"] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_update.message.reply_text.assert_called_once() + assert "bukan member" in mock_update.message.reply_text.call_args.args[0] + mock_context.bot.send_message.assert_not_called() + + async def test_no_reply_no_args_shows_usage( + self, mock_update, mock_context, mock_registry + ): + """No reply and no args shows usage error.""" + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_update.message.reply_text.assert_called_once() + assert "Penggunaan" in mock_update.message.reply_text.call_args.args[0] + assert mock_update.message.reply_text.call_args.kwargs.get("do_quote") is False + mock_context.bot.send_message.assert_not_called() + + async def test_invalid_user_id_shows_usage( + self, mock_update, mock_context, mock_registry + ): + """Non-numeric user ID shows usage error.""" + mock_context.args = ["not_a_number"] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_update.message.reply_text.assert_called_once() + assert "Penggunaan" in mock_update.message.reply_text.call_args.args[0] + + async def test_get_chat_member_failure_shows_error( + self, mock_update, mock_context, mock_registry + ): + """If get_chat_member fails, shows error.""" + mock_context.args = ["67890"] + mock_context.bot.get_chat_member.side_effect = Exception("User not found") + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_update.message.reply_text.assert_called_once() + assert "67890" in mock_update.message.reply_text.call_args.args[0] + mock_context.bot.send_message.assert_not_called() + + async def test_warn_bot_silent_ignore( + self, mock_update, mock_context, mock_registry + ): + """Cannot warn a bot — silent ignore.""" + target = _make_target_user() + target.is_bot = True + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = [] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_not_called() + + async def test_warn_self_silent_ignore( + self, mock_update, mock_context, mock_registry + ): + """Admin cannot warn themselves — silent ignore.""" + target = _make_target_user(user_id=12345) # same as admin + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = [] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_not_called() + + async def test_unmonitored_group_silent_ignore( + self, mock_update, mock_context + ): + """Command in a non-monitored group is silently ignored.""" + with patch("bot.handlers.warn.get_group_config_for_update", return_value=None): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_not_called() + mock_update.message.reply_text.assert_not_called() + mock_update.message.delete.assert_not_called() + + async def test_send_message_failure_does_not_break( + self, mock_update, mock_context, mock_registry + ): + """If send_message fails, handler exits gracefully.""" + target = _make_target_user() + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = [] + mock_context.bot.send_message.side_effect = Exception("Flood control") + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + mock_update.message.delete.assert_called_once() + + async def test_delete_failure_does_not_break( + self, mock_update, mock_context, mock_registry + ): + """Warning still sent even if message deletion fails.""" + from telegram.error import TelegramError + target = _make_target_user() + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = [] + mock_update.message.delete.side_effect = TelegramError("no permission") + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + mock_update.message.delete.assert_called_once() + + async def test_reply_without_from_user_falls_to_id_mode( + self, mock_update, mock_context, mock_registry + ): + """Reply to a channel message (no from_user) falls through to ID mode.""" + target = _make_target_user() + mock_context.bot.get_chat_member.return_value = _make_chat_member(target) + mock_update.message.reply_to_message = MagicMock() + mock_update.message.reply_to_message.from_user = None + mock_context.args = ["67890", "spam"] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.get_chat_member.assert_called_once_with( + chat_id=-1001234567890, user_id=67890 + ) + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert "spam" in call_kwargs["text"] + assert "67890" not in call_kwargs["text"] or "badmember" in call_kwargs["text"] + + async def test_reason_with_markdown_is_escaped( + self, mock_update, mock_context, mock_registry + ): + """Reason with Markdown metacharacters is escaped.""" + target = _make_target_user() + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = ["stop", "_spamming_", "and", "[links]"] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + text = call_kwargs["text"] + assert "\\_spamming\\_" in text + assert "\\[links]" in text + + async def test_moderation_topic_when_configured( + self, mock_update, mock_context + ): + """When moderation_topic_id is set, warning goes to that topic.""" + config = GroupConfig( + group_id=-1001234567890, + warning_topic_id=12345, + moderation_topic_id=67890, + rules_link="https://t.me/test/rules", + ) + registry = GroupRegistry() + registry.register(config) + + target = _make_target_user() + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = [] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert call_kwargs["chat_id"] == -1001234567890 + assert call_kwargs["message_thread_id"] == 67890 + assert call_kwargs["message_thread_id"] != 12345 + mock_update.message.delete.assert_called_once() + + async def test_no_moderation_topic_sends_to_main_chat( + self, mock_update, mock_context + ): + """When moderation_topic_id is None, warning goes to main chat.""" + config = GroupConfig( + group_id=-1001234567890, + warning_topic_id=12345, + rules_link="https://t.me/test/rules", + ) + registry = GroupRegistry() + registry.register(config) + + target = _make_target_user() + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = [] + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert call_kwargs["chat_id"] == -1001234567890 + assert "message_thread_id" not in call_kwargs + + async def test_moderation_topic_id_defaults_to_none(self): + """moderation_topic_id defaults to None.""" + config = GroupConfig( + group_id=-1001234567890, + warning_topic_id=12345, + ) + assert config.moderation_topic_id is None + + async def test_command_deleted_before_send_message( + self, mock_update, mock_context, mock_registry + ): + """Command message is deleted before send_message is called.""" + target = _make_target_user() + mock_update.message.reply_to_message = _make_reply_message(target) + mock_context.args = [] + + call_order = [] + + async def mock_delete(): + call_order.append("delete") + + async def mock_send(**kwargs): + call_order.append("send_message") + + mock_update.message.delete = AsyncMock(side_effect=mock_delete) + mock_context.bot.send_message = AsyncMock(side_effect=mock_send) + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + assert call_order == ["delete", "send_message"] + + async def test_command_deleted_before_get_chat_member( + self, mock_update, mock_context, mock_registry + ): + """Command message is deleted before get_chat_member is called in ID mode.""" + target = _make_target_user() + mock_context.bot.get_chat_member.return_value = _make_chat_member(target) + mock_context.args = ["67890"] + + call_order = [] + + async def mock_delete(): + call_order.append("delete") + + async def mock_get_member(**kwargs): + call_order.append("get_chat_member") + return _make_chat_member(target) + + mock_update.message.delete = AsyncMock(side_effect=mock_delete) + mock_context.bot.get_chat_member = AsyncMock(side_effect=mock_get_member) + + with patch("bot.handlers.warn.get_group_config_for_update", return_value=mock_registry.get(-1001234567890)): + await handle_warn_command(mock_update, mock_context) + + assert call_order[0] == "delete" From 3078e057df6d737d1e9d1aa82dc99c527adddb2f Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Sat, 1 Aug 2026 12:51:39 +0700 Subject: [PATCH 2/2] docs: update AGENTS.md and README.md with /warn command Document the new /warn command, moderation_topic_id config option, warn_command plugin registration, and updated plugin/setting counts across both AGENTS.md and README.md. --- AGENTS.md | 17 +++++++++++------ README.md | 9 ++++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 725bff2..e048855 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ PythonID/ ├── src/bot/ │ ├── main.py # Entry point + handler registration (priority groups!) │ ├── config.py # Pydantic settings (get_settings() cached) -│ ├── constants.py # Indonesian templates + URL whitelists (528 lines) +│ ├── constants.py # Indonesian templates + URL whitelists (739 lines) │ ├── group_config.py # Multi-group config (GroupConfig, GroupRegistry) │ ├── plugins/ # Modular plugin system (wraps handlers) │ │ ├── manager.py # PluginManager — discovers + registers built-ins @@ -68,6 +68,7 @@ PythonID/ │ │ ├── dm.py # DM unrestriction flow │ │ ├── topic_guard.py # Warning topic protection (group=-1) │ │ ├── trust.py # /trust, /untrust, /trusted admin commands +│ │ ├── warn.py # Admin /warn command (reply or user ID) │ │ ├── duplicate_spam.py # Duplicate message detection │ │ └── bio_bait.py # Bio-bait spam (bait phrases + suspicious profile bio links) │ ├── services/ @@ -81,7 +82,8 @@ PythonID/ │ ├── models.py # SQLModel schemas (5 tables: UserWarning, PhotoVerificationWhitelist, PendingCaptchaValidation, NewUserProbation, TrustedUser) │ └── service.py # DatabaseService singleton (645 lines) ├── tests/ # pytest-asyncio + Hypothesis (30+ files) -│ └── test_properties.py # Property-based tests for pure functions +│ ├── test_properties.py # Property-based tests for pure functions +│ └── test_warn.py # /warn command tests (23 tests) ├── scripts/ │ └── backfill_trusted_names.py # One-shot backfill for trusted user names └── data/bot.db # SQLite (auto-created, WAL mode) @@ -98,6 +100,7 @@ PythonID/ | Add URL whitelist | `constants.py` → `WHITELISTED_URL_DOMAINS` | Suffix-based matching | | Add Telegram whitelist | `constants.py` → `WHITELISTED_TELEGRAM_PATHS` | Lowercase, exact path match | | Multi-group config | `group_config.py` | GroupConfig model, GroupRegistry, groups.json loading | +| Warn a member | `handlers/warn.py` + `plugins/builtin/commands.py` | Admin `/warn` by reply or user ID; registered as `warn_command` | ## Code Map (Key Files) @@ -114,6 +117,7 @@ PythonID/ | `handlers/dm.py` | 250 | DM unrestriction flow with deep-link group recovery | | `handlers/message.py` | 208 | Profile compliance monitoring + stale warning clearing | | `handlers/status.py` | 181 | Group-scoped /status (admin's groups only, Indonesian labels) | +| `handlers/warn.py` | 170 | Admin-issued generic warning by reply or user ID; optional moderation-topic routing | | `services/scheduler.py` | 151 | Auto-restriction with pre-restriction profile recheck | | `group_config.py` | 255 | Multi-group config, registry, JSON loading, .env fallback | | `main.py` | 191 | Entry point, logging, post_init, PluginManager bootstrap | @@ -128,7 +132,7 @@ PythonID/ ### Modular Plugin System - Built-in plugins live in `src/bot/plugins/builtin/`, one per handler domain (captcha, spam, topic_guard, profile_monitor, commands, dm, jobs) -- `plugins/definitions.py` holds `MANIFEST_ORDER` — a static, hand-maintained tuple of 25 plugin names (topic_guard first, job plugins last) that is the single source of truth for registration order and for the group number each plugin runs in +- `plugins/definitions.py` holds `MANIFEST_ORDER` — a static, hand-maintained tuple of 27 plugin names (topic_guard first, job plugins last) that is the single source of truth for registration order and for the group number each plugin runs in - `PluginManager.register_all()` (called from `main.py:main`, not `post_init`) walks `MANIFEST_ORDER` against a static `_REGISTRY` dict (name → registrar function) and stores results in `application.bot_data["plugin_handlers"]` - The plugin wrapper pattern: `bot.plugins.builtin.X` imports from `bot.handlers.X`, clones the handler list, and applies `guard_plugin("X")` for per-group runtime gating - To add a new plugin: add a `register_*(application) -> list[BaseHandler]` function in `builtin/`, add its name + group to `_PLUGIN_DEFINITIONS` in `definitions.py`, wire it into `_REGISTRY` in `manager.py` @@ -141,7 +145,7 @@ PythonID/ ```python # Registration order comes from MANIFEST_ORDER (plugins/definitions.py), not main.py directly group=-1 # topic_guard: Runs FIRST -group=0 # commands, verify/unverify/check/trust callbacks, captcha, dm (14 plugins, order-independent) +group=0 # commands (including warn_command), callbacks, captcha, dm (18 plugins, order-independent) group=1 # inline_keyboard_spam: Catches inline keyboard URL spam group=2 # contact_spam: Blocks contact card sharing group=3 # new_user_spam: Probation enforcement (links/forwards) @@ -191,7 +195,7 @@ group=6 # JobQueue only (not a handler group): auto_restrict_job, refresh_admi - Handler + JobQueue registration (`PluginManager.register_all()`) and effective-plugin-map computation happen later, in `main()` after `post_init` is wired up but before `run_polling` — not inside `post_init` itself ### Multi-Group Support -- `GroupConfig` — Pydantic model with 20 per-group settings: warning thresholds, captcha, probation, contact/duplicate/bio-bait spam tuning, `rules_link`, and a `plugins: dict[str, bool] | None` override +- `GroupConfig` — Pydantic model with 21 per-group settings: warning thresholds, captcha, probation, contact/duplicate/bio-bait spam tuning, `rules_link`, optional `moderation_topic_id`, and a `plugins: dict[str, bool] | None` override - `GroupRegistry` — O(1) lookup by group_id, manages all monitored groups - `groups.json` — Per-group config file; falls back to `.env` for single-group mode (missing fields default from `GroupConfig.model_fields`) - `get_group_config_for_update()` — Helper to resolve config for incoming Telegram updates @@ -290,7 +294,7 @@ if user.id not in admin_ids: ## Notes -- Registration order for all 25 built-in plugins lives in `MANIFEST_ORDER` (`plugins/definitions.py`), not scattered across `main.py` +- Registration order for all 27 built-in plugins lives in `MANIFEST_ORDER` (`plugins/definitions.py`), not scattered across `main.py` - `duplicate_spam` and `bio_bait_spam` both run at `group=4`; `auto_restrict_job` / `refresh_admin_ids_job` run as JobQueue jobs tagged `group=6` (not a PTB handler group) - Topic guard runs at `group=-1` to intercept unauthorized messages BEFORE other handlers - Topic guard handles both messages and edited messages, raises `ApplicationHandlerStop` to block downstream handlers @@ -302,6 +306,7 @@ if user.id not in admin_ids: - Captcha callback data encodes group_id: `captcha_verify_{group_id}_{user_id}` to avoid ambiguity - Scheduler iterates all groups with per-group exception isolation - DM handler scans all groups in registry for user membership and unrestriction +- **Warn command**: A per-group admin can reply with `/warn [reason]` or use `/warn USER_ID [reason]`. The command is deleted before network lookups to protect the admin's identity; non-admins, bots, and self-targets are silently ignored. ID mode verifies membership with `get_chat_member`, reasons are Markdown-escaped, and the warning is sent to `moderation_topic_id` when configured or the main group otherwise. `moderation_topic_id` is distinct from `warning_topic_id`, which is used for bot logging. This command creates no DB record and is not gated by `guard_plugin` - **Trust feature**: `TrustedUser` table caches user_full_name + admin_full_name at trust time so `/trusted` lists admin info without Telegram API calls. Backfill script at `scripts/backfill_trusted_names.py` for pre-existing rows - **Local review artifacts**: `reviews/` directory contains output from parallel reviewer subagents. Gitignored; not part of the source tree - **Captcha DB ordering**: The captcha callback handler calls Telegram `unrestrict_user` BEFORE DB writes (remove_pending_captcha, start_new_user_probation). If unrestrict fails, the pending captcha stays in DB and the user can retry. DB finalization is idempotent — `remove_pending_captcha` returning False means a concurrent callback already finalized diff --git a/README.md b/README.md index a537ac5..d0503fc 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ A comprehensive Telegram bot for managing group members with profile verificatio - **/verify command**: Whitelist users with hidden profile pictures (DM only) - **/unverify command**: Remove users from verification whitelist (DM only) - **Inline verification**: Forward messages to bot for quick verify/unverify buttons +- **/warn command**: Admin-issued bot warnings to in-group members by replying with `/warn [reason]` or using `/warn USER_ID [reason]`; optional reasons are Markdown-escaped, and the admin's command is deleted to protect their identity. Authorization is per-group, ID targets are checked for active membership, and bots, self-targets, and non-admin callers are silently ignored. Warnings go to `moderation_topic_id` when configured, otherwise to the main group chat, and do not create progressive-enforcement database records. - **/trust command**: Add trusted users (DM only, supports user ID or forwarded message) - **/untrust command**: Remove trusted users from trusted list (DM only) - **/trusted command**: List all trusted users (DM only) @@ -124,6 +125,7 @@ Add `GROUPS_CONFIG_PATH=groups.json` to your `.env` file, then edit `groups.json { "group_id": -1001234567890, "warning_topic_id": 123, + "moderation_topic_id": null, "restrict_failed_users": false, "warning_threshold": 3, "warning_time_threshold_minutes": 180, @@ -136,6 +138,7 @@ Add `GROUPS_CONFIG_PATH=groups.json` to your `.env` file, then edit `groups.json { "group_id": -1009876543210, "warning_topic_id": 456, + "moderation_topic_id": null, "restrict_failed_users": true, "warning_threshold": 5, "warning_time_threshold_minutes": 60, @@ -148,7 +151,7 @@ Add `GROUPS_CONFIG_PATH=groups.json` to your `.env` file, then edit `groups.json ] ``` -When `groups.json` is present, per-group settings override the `.env` defaults. Each group can have its own warning thresholds, captcha settings, probation rules, and rules link. Each group entry can also add a `"plugins": {"bio_bait_spam": false}`-style object to disable specific built-in plugins just for that group, overriding the bot-wide `PLUGINS_DEFAULT`. +When `groups.json` is present, per-group settings override the `.env` defaults. Each group can have its own warning thresholds, moderation topic (`moderation_topic_id`), captcha settings, probation rules, and rules link. Each group entry can also add a `"plugins": {"bio_bait_spam": false}`-style object to disable specific built-in plugins just for that group, overriding the bot-wide `PLUGINS_DEFAULT`. **Backward compatibility**: If no `groups.json` is configured (i.e., `GROUPS_CONFIG_PATH` is not set), the bot falls back to single-group mode using `GROUP_ID`, `WARNING_TOPIC_ID`, and other settings from `.env`. @@ -262,6 +265,7 @@ PythonID/ │ ├── test_trust_handler.py │ ├── test_user_checker.py │ ├── test_verify_handler.py +│ ├── test_warn.py │ └── test_whitelist.py └── src/ └── bot/ @@ -289,6 +293,7 @@ PythonID/ │ ├── topic_guard.py # Warning topic protection │ ├── trust.py # /trust, /untrust, /trusted admin commands │ ├── verify.py # /verify and /unverify command handlers + │ ├── warn.py # Per-group admin /warn command │ ├── duplicate_spam.py # Duplicate message detection │ └── bio_bait.py # Bio-bait spam (bait phrases + suspicious profile bio links) ├── database/ @@ -583,6 +588,7 @@ The bot is organized into clear modules for maintainability: - `verify.py`: /verify and /unverify command handlers - `check.py`: /check command + forwarded message handling - `trust.py`: /trust, /untrust, /trusted admin commands (TrustedUser table caches names at trust time so /trusted renders without API calls) + - `warn.py`: Per-group admin /warn messages by reply or member ID, routed to the configured moderation topic without database enforcement records - **services/**: Business logic and utilities - `scheduler.py`: JobQueue background job that runs every 5 minutes for time-based auto-restrictions - `user_checker.py`: Profile validation (photo + username check) — used by both the captcha gate and the per-message monitor @@ -673,6 +679,7 @@ When a restricted user DMs the bot (or sends `/start`): | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Required | | `GROUP_ID` | Group ID to monitor (negative number) | Required | | `WARNING_TOPIC_ID` | Topic ID for warning messages | Required | +| `MODERATION_TOPIC_ID` | Topic ID for admin /warn moderation messages (optional) | None | | `RESTRICT_FAILED_USERS` | Enable progressive restriction mode | `false` | | `WARNING_THRESHOLD` | Messages before restriction (message-based) | `3` | | `WARNING_TIME_THRESHOLD_MINUTES` | Minutes before auto-restriction (time-based) | `180` (3 hours) |