From 3aed078bbb8229771c26bc705ea1e69705b72919 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:53:59 +0800 Subject: [PATCH] fix(agent): bind channel admin identity (#6294) --- app/agent/__init__.py | 9 + app/agent/tools/base.py | 75 +--- app/chain/message.py | 16 + app/helper/agent.py | 21 + app/modules/discord/__init__.py | 11 +- app/modules/feishu/feishu.py | 13 + app/modules/qqbot/__init__.py | 9 +- app/modules/slack/__init__.py | 15 +- app/modules/synologychat/__init__.py | 8 +- app/modules/telegram/__init__.py | 11 +- app/modules/vocechat/__init__.py | 13 +- app/modules/wechat/__init__.py | 9 +- app/modules/wechatclawbot/__init__.py | 4 + app/schemas/message.py | 2 + tests/test_agent_channel_admin_identity.py | 425 ++++++++++++++++++ tests/test_agent_message_routing.py | 71 +++ tests/test_agent_resource_flow_permissions.py | 76 ++++ tests/test_agent_scheduled_tasks.py | 34 ++ tests/test_message_processing_status.py | 4 +- 19 files changed, 752 insertions(+), 74 deletions(-) create mode 100644 tests/test_agent_channel_admin_identity.py diff --git a/app/agent/__init__.py b/app/agent/__init__.py index dfbbbbad3..3f284e3c1 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -373,6 +373,7 @@ class MoviePilotAgent: channel: str = None, source: str = None, username: str = None, + is_channel_admin: Optional[bool] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None, replay_mode: ReplyMode = ReplyMode.DISPATCH, @@ -385,6 +386,7 @@ class MoviePilotAgent: self.channel = channel self.source = source self.username = username + self.is_channel_admin = is_channel_admin self.original_message_id = original_message_id self.original_chat_id = original_chat_id self.reply_mode = replay_mode @@ -934,6 +936,8 @@ class MoviePilotAgent: "anthropic", }: return True + if self.channel and self.channel != MessageChannel.Web.value: + return self.is_channel_admin is True if not self.username: return False try: @@ -2429,6 +2433,7 @@ class _MessageTask: channel: Optional[str] = None source: Optional[str] = None username: Optional[str] = None + is_channel_admin: Optional[bool] = None original_message_id: Optional[str] = None original_chat_id: Optional[str] = None processing_status: Optional[dict] = None @@ -2596,6 +2601,7 @@ class AgentManager: channel: str = None, source: str = None, username: str = None, + is_channel_admin: Optional[bool] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None, reply_mode: ReplyMode = ReplyMode.DISPATCH, @@ -2623,6 +2629,7 @@ class AgentManager: channel=channel, source=source, username=username, + is_channel_admin=is_channel_admin, original_message_id=original_message_id, original_chat_id=original_chat_id, reply_mode=reply_mode, @@ -2773,6 +2780,7 @@ class AgentManager: "channel": task.channel, "source": task.source, "username": task.username, + "is_channel_admin": task.is_channel_admin, "original_message_id": task.original_message_id, "original_chat_id": task.original_chat_id, "replay_mode": task.reply_mode, @@ -2792,6 +2800,7 @@ class AgentManager: agent.channel = task.channel agent.source = task.source agent.username = task.username + agent.is_channel_admin = task.is_channel_admin agent.original_message_id = task.original_message_id agent.original_chat_id = task.original_chat_id agent.reply_mode = task.reply_mode diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index dbd6b44a5..c1c5209ed 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -19,7 +19,7 @@ from app.agent.policy.sanitizer import ( from app.agent.tools.tags import ToolTag from app.chain import ChainBase from app.core.config import settings -from app.db.user_oper import UserOper +from app.helper.agent import matches_channel_admin from app.helper.service import ServiceConfigHelper from app.log import logger from app.schemas import Notification @@ -403,8 +403,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): :return: 当前调用者是系统管理员、渠道管理员或显式管理员上下文时返回 True """ - if bool(self._agent_context.get("is_admin")): - return True + if "is_admin" in self._agent_context: + return self._agent_context.get("is_admin") is True if not self._channel or not self._source: return False @@ -509,12 +509,10 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): async def _check_permission(self) -> Optional[str]: """ - 检查用户权限: - 1. 首先检查工具是否需要管理员权限 - 2. 如果需要管理员权限,则检查用户是否是渠道管理员 - 3. 如果渠道没有设置管理员名单,则检查用户是否是系统管理员 - 4. 如果都不是系统管理员,检查用户ID是否等于渠道配置的用户ID - 5. 如果都不是,返回权限拒绝消息 + 检查管理员工具权限。 + + Agent 共享上下文中的显式管理员事实优先;没有该事实的旧调用才按渠道 + 管理员名单回查,并保留无消息渠道内部调用的兼容行为。 """ if not self._require_admin: return None @@ -522,7 +520,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): if await self.is_admin_user(): return None - if not self._channel or not self._source: + if "is_admin" not in self._agent_context and ( + not self._channel or not self._source + ): return None return ( @@ -536,13 +536,11 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): """ 检查当前消息渠道身份是否具备管理员权限。 - :return: 当前渠道用户是渠道管理员、系统管理员或默认接收人时返回 True + :return: 当前渠道稳定用户 ID 位于显式管理员名单时返回 True """ if not self._channel or not self._source: return False - # 渠道配置来自 SystemConfigOper 内存缓存,可以直接读取; - # 只有用户信息需要走异步数据库查询。 user_id_str = str(self._user_id) if self._user_id else None channel_type_map = { @@ -578,58 +576,17 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): "qqbot": "QQBOT_ADMINS", } - user_id_key_map = { - "telegram": "TELEGRAM_CHAT_ID", - "vocechat": "VOCECHAT_CHANNEL_ID", - "wechat": "WECHAT_BOT_CHAT_ID", - "feishu": "FEISHU_OPEN_ID", - "wechatclawbot": "WECHATCLAWBOT_DEFAULT_TARGET", - "discord": "DISCORD_CHANNEL_ID", - "slack": "SLACK_CHANNEL", - "qqbot": "QQ_OPENID", - } - admin_key = admin_key_map.get(channel_type) - user_id_key = user_id_key_map.get(channel_type) try: configs = ServiceConfigHelper.get_notification_configs() for config in configs: if config.name == self._source and config.config: - channel_admins = config.config.get(admin_key) if admin_key else None - if channel_admins: - admin_list = [ - aid.strip() - for aid in str(channel_admins).split(",") - if aid.strip() - ] - if user_id_str and user_id_str in admin_list: - return True - - user = ( - await UserOper().async_get_by_name(self._username) - if self._username - else None - ) - if user and user.is_superuser: - return True - - return False - else: - user = ( - await UserOper().async_get_by_name(self._username) - if self._username - else None - ) - if user and user.is_superuser: - return True - - if user_id_key: - config_user_id = config.config.get(user_id_key) - if config_user_id and str(config_user_id) == user_id_str: - return True - - return False + return matches_channel_admin( + config.config, + admin_key, + user_id_str, + ) except Exception as e: logger.error(f"检查权限失败: {summarize_error(e)}") diff --git a/app/chain/message.py b/app/chain/message.py index 0e09b4d89..41d7ff6a1 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -152,6 +152,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=info.is_channel_admin, text=text, original_message_id=original_message_id, original_chat_id=original_chat_id, @@ -174,6 +175,7 @@ class MessageChain(ChainBase): audio_refs: Optional[List[str]] = None, files: Optional[List[CommingMessage.MessageAttachment]] = None, reply_to_message_id: Optional[Union[str, int]] = None, + is_channel_admin: Optional[bool] = None, ) -> None: """ 识别消息内容,执行操作 @@ -214,6 +216,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=is_channel_admin, text=text, original_message_id=original_message_id, original_chat_id=original_chat_id, @@ -271,6 +274,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=is_channel_admin, text=text, original_message_id=original_message_id, original_chat_id=original_chat_id, @@ -305,6 +309,7 @@ class MessageChain(ChainBase): audio_refs: Optional[List[str]] = None, files: Optional[List[CommingMessage.MessageAttachment]] = None, has_audio_input: bool = False, + is_channel_admin: Optional[bool] = None, ) -> bool: """将 TG/飞书中的确认控制文本交回所属 Agent 会话。""" if channel not in {MessageChannel.Telegram, MessageChannel.Feishu}: @@ -331,6 +336,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=is_channel_admin, original_message_id=original_message_id, original_chat_id=original_chat_id, images=images, @@ -354,6 +360,7 @@ class MessageChain(ChainBase): has_audio_input: bool = False, processing_status: Optional[_ProcessingStatus] = None, reply_to_message_id: Optional[Union[str, int]] = None, + is_channel_admin: Optional[bool] = None, ) -> bool: """执行实际消息路由,便于统一包裹处理中状态。""" @@ -365,6 +372,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=is_channel_admin, original_message_id=original_message_id, original_chat_id=original_chat_id, processing_status=processing_status, @@ -430,6 +438,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=is_channel_admin, original_message_id=original_message_id, original_chat_id=original_chat_id, images=images, @@ -490,6 +499,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=is_channel_admin, original_message_id=original_message_id, original_chat_id=original_chat_id, images=images, @@ -747,6 +757,7 @@ class MessageChain(ChainBase): original_message_id: Optional[Union[str, int]] = None, original_chat_id: Optional[str] = None, processing_status: Optional[_ProcessingStatus] = None, + is_channel_admin: Optional[bool] = None, ) -> bool: """ 处理按钮回调 @@ -815,6 +826,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=is_channel_admin, original_message_id=original_message_id, original_chat_id=original_chat_id, ): @@ -954,6 +966,7 @@ class MessageChain(ChainBase): username: str, original_message_id: Optional[Union[str, int]] = None, original_chat_id: Optional[str] = None, + is_channel_admin: Optional[bool] = None, ) -> bool: """ 将 Agent 按钮选择回传为同一会话中的下一条用户消息。 @@ -999,6 +1012,7 @@ class MessageChain(ChainBase): source=source, userid=userid, username=username, + is_channel_admin=is_channel_admin, session_id=request.session_id, ) @@ -1527,6 +1541,7 @@ class MessageChain(ChainBase): files: Optional[List[CommingMessage.MessageAttachment]] = None, session_id: Optional[str] = None, has_audio_input: bool = False, + is_channel_admin: Optional[bool] = None, ) -> bool: """ 处理AI智能体消息 @@ -1644,6 +1659,7 @@ class MessageChain(ChainBase): "channel": channel.value if channel else None, "source": source, "username": username, + "is_channel_admin": is_channel_admin, "original_message_id": str(original_message_id) if original_message_id else None, diff --git a/app/helper/agent.py b/app/helper/agent.py index a3708c4bf..40f24b2fd 100644 --- a/app/helper/agent.py +++ b/app/helper/agent.py @@ -7,6 +7,27 @@ _WEB_AGENT_EDIT_QUEUES: dict[str, list[Queue[dict]]] = {} _WEB_AGENT_EDIT_LOCK = Lock() +def matches_channel_admin( + config: Optional[dict], + admin_key: str, + *principal_ids: Optional[Union[str, int]], +) -> bool: + """按渠道配置中的稳定主体 ID 判断管理员身份。""" + admins = { + item.strip() + for item in str((config or {}).get(admin_key) or "").split(",") + if item.strip() + } + if not admins: + return False + candidates = { + str(principal_id).strip() + for principal_id in principal_ids + if principal_id is not None and str(principal_id).strip() + } + return bool(admins.intersection(candidates)) + + def normalize_web_agent_button_rows(buttons: Optional[list[list[dict]]]) -> list[list[dict]]: """ 将消息按钮转换为 WebAgent 前端可识别的按钮行。 diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index a3bd73428..93947d54e 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -5,6 +5,7 @@ from urllib.parse import quote, unquote from app.core.context import MediaInfo, Context from app.core.event import eventmanager +from app.helper.agent import matches_channel_admin from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.schemas import ( @@ -194,7 +195,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): chat_id = msg_json.get("chat_id") if callback_data and userid: if str(callback_data).strip().startswith("/") and self._should_reject_admin_command( - client_config.config, userid, username + client_config.config, userid ): self._send_admin_denied(client, userid, chat_id) return None @@ -207,6 +208,9 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): source=client_config.name, userid=userid, username=username, + is_channel_admin=matches_channel_admin( + client_config.config, "DISCORD_ADMINS", userid + ), text=f"CALLBACK:{callback_data}", is_callback=True, callback_data=callback_data, @@ -223,7 +227,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): files = self._extract_files(msg_json) if (text or images or audio_refs or files) and userid: if text and text.startswith("/") and self._should_reject_admin_command( - client_config.config, userid, username + client_config.config, userid ): self._send_admin_denied(client, userid, chat_id) return None @@ -238,6 +242,9 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): source=client_config.name, userid=userid, username=username, + is_channel_admin=matches_channel_admin( + client_config.config, "DISCORD_ADMINS", userid + ), text=text, chat_id=str(chat_id) if chat_id else None, images=images, diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index 07004368f..0d6536e2a 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -53,6 +53,7 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import ( from app.core.config import settings from app.core.context import Context, MediaInfo from app.db.user_oper import UserOper +from app.helper.agent import matches_channel_admin from app.log import logger from app.schemas import CommingMessage, Notification from app.schemas.types import MessageChannel, NotificationType @@ -687,6 +688,12 @@ class Feishu: source=self._name, userid=userid, username=username, + is_channel_admin=matches_channel_admin( + {"FEISHU_ADMINS": ",".join(self._admins)}, + "FEISHU_ADMINS", + open_id, + user_id, + ), text=f"CALLBACK:{callback_data}", is_callback=True, callback_data=callback_data, @@ -724,6 +731,12 @@ class Feishu: source=self._name, userid=userid, username=username, + is_channel_admin=matches_channel_admin( + {"FEISHU_ADMINS": ",".join(self._admins)}, + "FEISHU_ADMINS", + open_id, + user_id, + ), text=text, message_id=message.get("message_id"), chat_id=message.get("chat_id"), diff --git a/app/modules/qqbot/__init__.py b/app/modules/qqbot/__init__.py index 5a5935b94..c66194286 100644 --- a/app/modules/qqbot/__init__.py +++ b/app/modules/qqbot/__init__.py @@ -9,6 +9,7 @@ from urllib.parse import quote, unquote from typing import Optional, List, Tuple, Union, Any from app.core.context import MediaInfo, Context +from app.helper.agent import matches_channel_admin from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.qqbot.qqbot import QQBot @@ -174,6 +175,9 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]): source=client_config.name, userid=user_openid, username=user_openid, + is_channel_admin=matches_channel_admin( + client_config.config, "QQBOT_ADMINS", user_openid + ), text=content, images=images, audio_refs=audio_refs, @@ -186,7 +190,7 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]): # 群聊用 group:group_openid 作为 userid,便于回复时识别 userid = f"group:{group_openid}" if group_openid else member_openid if content.startswith("/") and self._should_reject_admin_command( - client_config.config, member_openid, userid + client_config.config, member_openid ): self._send_admin_denied(client, userid) return None @@ -200,6 +204,9 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]): source=client_config.name, userid=userid, username=member_openid or group_openid, + is_channel_admin=matches_channel_admin( + client_config.config, "QQBOT_ADMINS", member_openid + ), text=content, images=images, audio_refs=audio_refs, diff --git a/app/modules/slack/__init__.py b/app/modules/slack/__init__.py index 06f2ba56e..e3609cb6e 100644 --- a/app/modules/slack/__init__.py +++ b/app/modules/slack/__init__.py @@ -6,6 +6,7 @@ from urllib.parse import quote, unquote from app.core.context import MediaInfo, Context from app.core.event import eventmanager +from app.helper.agent import matches_channel_admin from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.slack.slack import Slack @@ -279,7 +280,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): text = msg_json.get("text") username = msg_json.get("user") if text and text.startswith("/") and self._should_reject_admin_command( - client_config.config, userid, username + client_config.config, userid ): self._send_admin_denied(client, userid) return None @@ -295,7 +296,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): text = f"CALLBACK:{callback_data}" username = msg_json.get("user", {}).get("name") if str(callback_data).strip().startswith("/") and self._should_reject_admin_command( - client_config.config, userid, username + client_config.config, userid ): self._send_admin_denied(client, userid) return None @@ -319,6 +320,9 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): source=client_config.name, userid=userid, username=username, + is_channel_admin=matches_channel_admin( + client_config.config, "SLACK_ADMINS", userid + ), text=text, is_callback=True, callback_data=callback_data, @@ -349,7 +353,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): text = msg_json.get("callback_id") username = msg_json.get("user", {}).get("username") if text and text.startswith("/") and self._should_reject_admin_command( - client_config.config, userid, username + client_config.config, userid ): self._send_admin_denied(client, userid) return None @@ -358,7 +362,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): text = msg_json.get("command") username = msg_json.get("user_name") chat_id = msg_json.get("channel_id") - if self._should_reject_admin_command(client_config.config, userid, username): + if self._should_reject_admin_command(client_config.config, userid): self._send_admin_denied(client, userid) return None else: @@ -373,6 +377,9 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): source=client_config.name, userid=userid, username=username, + is_channel_admin=matches_channel_admin( + client_config.config, "SLACK_ADMINS", userid + ), text=text, message_id=message_id, chat_id=chat_id, diff --git a/app/modules/synologychat/__init__.py b/app/modules/synologychat/__init__.py index 6eedd75f7..c2c88a234 100644 --- a/app/modules/synologychat/__init__.py +++ b/app/modules/synologychat/__init__.py @@ -3,6 +3,7 @@ from typing import Optional, Union, List, Tuple, Any from urllib.parse import quote, unquote from app.core.context import MediaInfo, Context +from app.helper.agent import matches_channel_admin from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.synologychat.synologychat import SynologyChat @@ -168,7 +169,7 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]): files = self._extract_files(message) if (text or images or audio_refs or files) and user_id: if text and text.startswith("/") and self._should_reject_admin_command( - client_config.config, user_id, user_name + client_config.config, user_id ): self._send_admin_denied(client, user_id) return None @@ -179,7 +180,10 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]): f"files={len(files) if files else 0}" ) return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name, - userid=user_id, username=user_name, text=text or "", + userid=user_id, username=user_name, + is_channel_admin=matches_channel_admin( + client_config.config, "SYNOLOGYCHAT_ADMINS", user_id + ), text=text or "", images=images, audio_refs=audio_refs, files=files) except Exception as err: logger.debug(f"解析SynologyChat消息失败:{str(err)}") diff --git a/app/modules/telegram/__init__.py b/app/modules/telegram/__init__.py index 73c34a03b..3ff3fc73d 100644 --- a/app/modules/telegram/__init__.py +++ b/app/modules/telegram/__init__.py @@ -5,6 +5,7 @@ from typing import Dict, Optional, Union, List, Tuple, Any from app.core.context import MediaInfo, Context from app.core.event import eventmanager +from app.helper.agent import matches_channel_admin from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.telegram.telegram import Telegram @@ -211,7 +212,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): if callback_data and user_id: if str(callback_data).strip().startswith("/") and self._should_reject_admin_command( - client_config.config, user_id, user_name + client_config.config, user_id ): if client: client.answer_callback_query( @@ -235,6 +236,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): source=client_config.name, userid=user_id, username=user_name, + is_channel_admin=matches_channel_admin( + client_config.config, "TELEGRAM_ADMINS", user_id + ), text=callback_text, is_callback=True, callback_data=callback_data, @@ -293,7 +297,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): user_list = client_config.config.get("TELEGRAM_USERS") if cleaned_text and cleaned_text.startswith("/"): - if self._should_reject_admin_command(client_config.config, user_id, user_name): + if self._should_reject_admin_command(client_config.config, user_id): client.send_msg( title="只有管理员才有权限执行此命令", userid=user_id ) @@ -311,6 +315,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): source=client_config.name, userid=user_id, username=user_name, + is_channel_admin=matches_channel_admin( + client_config.config, "TELEGRAM_ADMINS", user_id + ), text=cleaned_text, message_id=message_id, chat_id=str(chat_id) if chat_id else None, diff --git a/app/modules/vocechat/__init__.py b/app/modules/vocechat/__init__.py index 076ec3325..823df84f6 100644 --- a/app/modules/vocechat/__init__.py +++ b/app/modules/vocechat/__init__.py @@ -3,6 +3,7 @@ from urllib.parse import quote, unquote from typing import Optional, Union, List, Tuple, Any, Dict from app.core.context import Context, MediaInfo +from app.helper.agent import matches_channel_admin from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.vocechat.vocechat import VoceChat @@ -180,6 +181,10 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]): text = content # 用户ID gid = msg_body.get("target", {}).get("gid") + from_uid = msg_body.get("from_uid") + if from_uid is None: + return None + actor_userid = f"UID#{from_uid}" channel_id = client_config.config.get("channel_id") if gid and str(gid) == str(channel_id): # 来自监听频道的消息 @@ -191,7 +196,7 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]): # 处理消息内容 if (text or images or audio_refs or files) and userid: if text and text.startswith("/") and self._should_reject_admin_command( - client_config.config, msg_body.get("from_uid"), userid + client_config.config, from_uid, actor_userid ): self._send_admin_denied(client, userid) return None @@ -201,7 +206,11 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]): f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}" ) return CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name, - userid=userid, username=userid, text=text or "", + userid=userid, username=userid, + is_channel_admin=matches_channel_admin( + client_config.config, "VOCECHAT_ADMINS", + from_uid, actor_userid, + ), text=text or "", images=images, audio_refs=audio_refs, files=files) except Exception as err: logger.error(f"VoceChat消息处理发生错误:{str(err)}") diff --git a/app/modules/wechat/__init__.py b/app/modules/wechat/__init__.py index e1e4e5e71..74298c6c7 100644 --- a/app/modules/wechat/__init__.py +++ b/app/modules/wechat/__init__.py @@ -7,6 +7,7 @@ from urllib.parse import quote from app.core.context import Context, MediaInfo from app.core.event import eventmanager +from app.helper.agent import matches_channel_admin from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt @@ -250,7 +251,10 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]): if content or images or audio_refs or files: # 处理消息内容 return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name, - userid=user_id, username=user_id, text=content or "", + userid=user_id, username=user_id, + is_channel_admin=matches_channel_admin( + client_config.config, "WECHAT_ADMINS", user_id + ), text=content or "", images=images, audio_refs=audio_refs, files=files) except Exception as err: logger.error(f"微信消息处理发生错误:{str(err)}") @@ -320,6 +324,9 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]): source=client_config.name, userid=sender, username=sender, + is_channel_admin=matches_channel_admin( + client_config.config, "WECHAT_ADMINS", sender + ), text=text or "", images=images, audio_refs=audio_refs, diff --git a/app/modules/wechatclawbot/__init__.py b/app/modules/wechatclawbot/__init__.py index 040607423..6f322108d 100644 --- a/app/modules/wechatclawbot/__init__.py +++ b/app/modules/wechatclawbot/__init__.py @@ -3,6 +3,7 @@ from typing import Any, List, Optional, Tuple, Union from app.core.cache import TTLCache from app.core.context import Context, MediaInfo +from app.helper.agent import matches_channel_admin from app.log import logger from app.modules import _MessageBase, _ModuleBase from app.modules.wechatclawbot.wechatclawbot import WechatClawBot @@ -198,6 +199,9 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]): source=client_config.name, userid=user_id, username=username, + is_channel_admin=matches_channel_admin( + client_config.config, "WECHATCLAWBOT_ADMINS", user_id + ), text=text, message_id=message_id, chat_id=str(message.get("chat_id") or "") or None, diff --git a/app/schemas/message.py b/app/schemas/message.py index 1110003b6..e5a7b8ae5 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -168,6 +168,8 @@ class CommingMessage(BaseModel): userid: Optional[Union[str, int]] = None # 用户名称 username: Optional[Union[str, int]] = None + # 渠道适配器依据稳定用户 ID 与当前实例管理员名单生成的授权事实 + is_channel_admin: Optional[bool] = None # 消息渠道 channel: Optional[MessageChannel] = None # 来源(渠道名称) diff --git a/tests/test_agent_channel_admin_identity.py b/tests/test_agent_channel_admin_identity.py new file mode 100644 index 000000000..2a1481a00 --- /dev/null +++ b/tests/test_agent_channel_admin_identity.py @@ -0,0 +1,425 @@ +import json +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from app.helper.agent import matches_channel_admin +from app.modules.discord import DiscordModule +from app.modules.feishu.feishu import Feishu +from app.modules.qqbot import QQBotModule +from app.modules.slack import SlackModule +from app.modules.synologychat import SynologyChatModule +from app.modules.telegram import TelegramModule +from app.modules.vocechat import VoceChatModule +from app.modules.wechat import WechatModule +from app.modules.wechatclawbot import WechatClawBotModule + + +def _parse_module_message(module, *, config: dict, body, client=None, form=None): + """使用隔离的渠道配置调用消息解析器。""" + client = client or SimpleNamespace() + with patch.object( + module, + "get_config", + return_value=SimpleNamespace(name="channel-test", config=config), + ), patch.object(module, "get_instance", return_value=client): + return module.message_parser( + source="channel-test", + body=body, + form=form or {}, + args={}, + ) + + +@pytest.mark.parametrize( + ("config", "principal_ids", "expected"), + [ + ({"ADMINS": " user-1, 42 "}, ("user-1",), True), + ({"ADMINS": "user-1,42"}, ("user-2", 7), False), + ({"ADMINS": ""}, ("user-1",), False), + ({}, ("user-1",), False), + (None, ("user-1",), False), + ], +) +def test_matches_channel_admin_uses_nonempty_stable_principal_set( + config, principal_ids, expected +): + assert matches_channel_admin(config, "ADMINS", *principal_ids) is expected + + +@pytest.mark.parametrize("message_kind", ["message", "callback"]) +def test_telegram_uses_user_id_not_same_named_username(message_kind): + module = TelegramModule() + client = SimpleNamespace(bot_username=None, answer_callback_query=Mock()) + if message_kind == "message": + payload = { + "message_id": 10, + "from": {"id": 10002, "username": "admin"}, + "chat": {"id": 10002}, + "text": "hello", + } + else: + payload = { + "callback_query": { + "id": "callback-1", + "from": {"id": 10002, "username": "admin"}, + "data": "choice:1", + "message": {"message_id": 10, "chat": {"id": 10002}}, + } + } + + message = _parse_module_message( + module, + config={"TELEGRAM_ADMINS": "admin,10001"}, + body=json.dumps(payload), + client=client, + ) + + assert message.userid == 10002 + assert message.username == "admin" + assert message.is_channel_admin is False + + +@pytest.mark.parametrize("message_kind", ["message", "callback"]) +def test_telegram_uses_stable_user_id_for_admin(message_kind): + module = TelegramModule() + client = SimpleNamespace(bot_username=None, answer_callback_query=Mock()) + if message_kind == "message": + payload = { + "message_id": 10, + "from": {"id": 10001, "username": "renamed-user"}, + "chat": {"id": 10001}, + "text": "hello", + } + else: + payload = { + "callback_query": { + "id": "callback-1", + "from": {"id": 10001, "username": "renamed-user"}, + "data": "choice:1", + "message": {"message_id": 10, "chat": {"id": 10001}}, + } + } + + message = _parse_module_message( + module, + config={"TELEGRAM_ADMINS": "10001"}, + body=json.dumps(payload), + client=client, + ) + + assert message.userid == 10001 + assert message.is_channel_admin is True + + +def test_telegram_slash_does_not_accept_admin_display_username(): + """Telegram 斜杠命令不得把可修改的 username 当作管理员 ID。""" + module = TelegramModule() + client = SimpleNamespace(bot_username=None, send_msg=Mock()) + message = _parse_module_message( + module, + config={"TELEGRAM_ADMINS": "admin"}, + body=json.dumps( + { + "message_id": 10, + "from": {"id": 10002, "username": "admin"}, + "chat": {"id": 10002}, + "text": "/sites", + } + ), + client=client, + ) + + assert message is None + client.send_msg.assert_called_once() + + +def test_telegram_empty_admin_list_keeps_legacy_slash_without_agent_admin(): + """空名单保持传统命令可用,但不能生成 Agent 管理员身份。""" + module = TelegramModule() + client = SimpleNamespace(bot_username=None, send_msg=Mock()) + message = _parse_module_message( + module, + config={"TELEGRAM_ADMINS": ""}, + body=json.dumps( + { + "message_id": 10, + "from": {"id": 10002, "username": "admin"}, + "chat": {"id": 10002}, + "text": "/sites", + } + ), + client=client, + ) + + assert message.is_channel_admin is False + client.send_msg.assert_not_called() + + +@pytest.mark.parametrize( + "payload", + [ + {"type": "message", "user": "UADMIN", "text": "hello"}, + { + "type": "block_actions", + "user": {"id": "UADMIN", "name": "renamed-user"}, + "actions": [{"value": "choice:1"}], + "message": {"ts": "1710000000.000100"}, + "container": {"channel_id": "C01"}, + }, + ], +) +def test_slack_message_and_callback_use_stable_user_id(payload): + message = _parse_module_message( + SlackModule(), + config={"SLACK_ADMINS": "UADMIN"}, + body=json.dumps(payload), + ) + + assert message.userid == "UADMIN" + assert message.is_channel_admin is True + + +def test_slack_slash_does_not_accept_admin_display_username(): + """Slack 原生斜杠命令只接受稳定 user_id,不接受 user_name。""" + client = SimpleNamespace(send_msg=Mock()) + message = _parse_module_message( + SlackModule(), + config={"SLACK_ADMINS": "admin"}, + body=json.dumps( + { + "command": "/sites", + "user_id": "UUSER", + "user_name": "admin", + "channel_id": "C01", + } + ), + client=client, + ) + + assert message is None + client.send_msg.assert_called_once() + + +def test_slack_empty_admin_list_keeps_legacy_slash_without_agent_admin(): + """Slack 空名单不预拦截命令,但 Agent 管理员事实仍为否。""" + client = SimpleNamespace(send_msg=Mock()) + message = _parse_module_message( + SlackModule(), + config={"SLACK_ADMINS": ""}, + body=json.dumps( + { + "command": "/sites", + "user_id": "UUSER", + "user_name": "admin", + "channel_id": "C01", + } + ), + client=client, + ) + + assert message.is_channel_admin is False + client.send_msg.assert_not_called() + + +@pytest.mark.parametrize( + "payload", + [ + { + "type": "message", + "userid": "discord-admin-id", + "username": "renamed-user", + "text": "hello", + }, + { + "type": "interaction", + "userid": "discord-admin-id", + "username": "renamed-user", + "callback_data": "choice:1", + }, + ], +) +def test_discord_message_and_callback_use_stable_user_id(payload): + message = _parse_module_message( + DiscordModule(), + config={"DISCORD_ADMINS": "discord-admin-id"}, + body=json.dumps(payload), + ) + + assert message.userid == "discord-admin-id" + assert message.is_channel_admin is True + + +@pytest.mark.parametrize( + ("payload", "admins"), + [ + ( + { + "text": "hello", + "sender": { + "open_id": "ou_admin", + "user_id": "u_other", + "name": "renamed-user", + }, + }, + "ou_admin", + ), + ( + { + "type": "cardAction", + "callback_data": "choice:1", + "sender": { + "open_id": "ou_other", + "user_id": "u_admin", + "name": "renamed-user", + }, + }, + "u_admin", + ), + ], +) +def test_feishu_message_and_card_callback_accept_open_id_or_user_id(payload, admins): + with patch.object(Feishu, "_build_api_client", return_value=Mock()), patch.object( + Feishu, "_start_ws_client" + ), patch("app.modules.feishu.feishu.UserOper") as user_oper: + user_oper.return_value.get_name.return_value = None + client = Feishu( + FEISHU_APP_ID="app-id", + FEISHU_APP_SECRET="app-secret", + FEISHU_ADMINS=admins, + name="feishu-test", + ) + message = client.parse_message(payload) + + assert message.userid == payload["sender"]["open_id"] + assert message.is_channel_admin is True + + +@pytest.mark.parametrize( + ("user_id", "username", "expected"), + [("wxid_admin", "renamed-user", True), ("wxid_user", "admin-name", False)], +) +def test_wechatclawbot_uses_channel_user_id_not_username(user_id, username, expected): + message = _parse_module_message( + WechatClawBotModule(), + config={"WECHATCLAWBOT_ADMINS": "admin-name,wxid_admin"}, + body={ + "__channel__": "wechatclawbot", + "userid": user_id, + "username": username, + "text": "hello", + }, + ) + + assert message.userid == user_id + assert message.is_channel_admin is expected + + +@pytest.mark.parametrize( + ("sender", "admins", "expected"), + [("wechat-admin", "wechat-admin", True), ("wechat-user", "display-admin", False)], +) +def test_wechat_bot_uses_sender_userid(sender, admins, expected): + message = _parse_module_message( + WechatModule(), + config={"WECHAT_MODE": "bot", "WECHAT_ADMINS": admins}, + body=json.dumps( + { + "body": { + "from": {"userid": sender}, + "msgtype": "text", + "text": {"content": "hello"}, + } + } + ), + ) + + assert message.userid == sender + assert message.is_channel_admin is expected + + +def test_qq_c2c_uses_user_openid_for_admin(): + message = _parse_module_message( + QQBotModule(), + config={"QQBOT_ADMINS": "qq-admin"}, + body={ + "type": "C2C_MESSAGE_CREATE", + "content": "hello", + "author": {"user_openid": "qq-admin"}, + }, + ) + + assert message.userid == "qq-admin" + assert message.is_channel_admin is True + + +@pytest.mark.parametrize( + ("admins", "expected"), + [("member-admin", True), ("group:group-admin", False), ("group-admin", False)], +) +def test_qq_group_uses_only_member_openid_for_admin(admins, expected): + message = _parse_module_message( + QQBotModule(), + config={"QQBOT_ADMINS": admins}, + body={ + "type": "GROUP_AT_MESSAGE_CREATE", + "content": "hello", + "author": {"member_openid": "member-admin"}, + "group_openid": "group-admin", + }, + ) + + assert message.userid == "group:group-admin" + assert message.username == "member-admin" + assert message.is_channel_admin is expected + + +@pytest.mark.parametrize( + ("admins", "expected"), + [("7", True), ("UID#7", True), ("GID#2", False)], +) +def test_vocechat_group_uses_only_sender_uid_for_admin(admins, expected): + message = _parse_module_message( + VoceChatModule(), + config={"VOCECHAT_ADMINS": admins, "channel_id": "2"}, + body=json.dumps( + { + "detail": { + "type": "normal", + "content_type": "text/plain", + "content": "hello", + }, + "from_uid": 7, + "target": {"gid": 2}, + } + ), + ) + + assert message.userid == "GID#2" + assert message.username == "GID#2" + assert message.is_channel_admin is expected + + +@pytest.mark.parametrize( + ("admins", "expected"), + [("42", True), ("display-admin", False)], +) +def test_synology_chat_uses_numeric_user_id_not_username(admins, expected): + client = SimpleNamespace(check_token=Mock(return_value=True)) + message = _parse_module_message( + SynologyChatModule(), + config={"SYNOLOGYCHAT_ADMINS": admins}, + body={}, + form={ + "token": "token", + "text": "hello", + "user_id": "42", + "username": "display-admin", + }, + client=client, + ) + + assert message.userid == 42 + assert message.username == "display-admin" + assert message.is_channel_admin is expected diff --git a/tests/test_agent_message_routing.py b/tests/test_agent_message_routing.py index c06575014..cd0f8af76 100644 --- a/tests/test_agent_message_routing.py +++ b/tests/test_agent_message_routing.py @@ -83,6 +83,75 @@ def test_explicit_ai_message_is_not_recorded_to_message_history(): process_message.assert_called_once() +def test_message_chain_passes_stable_channel_admin_principal_to_agent(): + """消息链应将渠道适配器生成的管理员事实传给 Agent。""" + chain = MessageChain() + + with patch.object(settings, "AI_AGENT_ENABLE", True), patch( + "app.chain.message.agent_manager.process_message", + new_callable=AsyncMock, + ) as process_message, patch( + "app.chain.message.asyncio.run_coroutine_threadsafe", + side_effect=lambda coro, _loop: (coro.close(), Mock())[1], + ): + chain.handle_message( + channel=MessageChannel.Telegram, + source="telegram-test", + userid="10001", + username="renamed-user", + is_channel_admin=True, + text="/ai 检查系统状态", + ) + + assert process_message.call_args.kwargs["is_channel_admin"] is True + + +def test_message_chain_does_not_trust_channel_display_username(): + """消息链应保留适配器给出的明确非管理员结论。""" + chain = MessageChain() + + with patch.object(settings, "AI_AGENT_ENABLE", True), patch( + "app.chain.message.agent_manager.process_message", + new_callable=AsyncMock, + ) as process_message, patch( + "app.chain.message.asyncio.run_coroutine_threadsafe", + side_effect=lambda coro, _loop: (coro.close(), Mock())[1], + ): + chain.handle_message( + channel=MessageChannel.Telegram, + source="telegram-test", + userid="10002", + username="admin", + is_channel_admin=False, + text="/ai 检查系统状态", + ) + + assert process_message.call_args.kwargs["is_channel_admin"] is False + + +def test_message_chain_uses_same_admin_contract_for_slack(): + """管理员事实透传应复用于其他消息渠道,而不是 Telegram 特判。""" + chain = MessageChain() + + with patch.object(settings, "AI_AGENT_ENABLE", True), patch( + "app.chain.message.agent_manager.process_message", + new_callable=AsyncMock, + ) as process_message, patch( + "app.chain.message.asyncio.run_coroutine_threadsafe", + side_effect=lambda coro, _loop: (coro.close(), Mock())[1], + ): + chain.handle_message( + channel=MessageChannel.Slack, + source="slack-test", + userid="UADMIN", + username="renamed-user", + is_channel_admin=True, + text="/ai 检查系统状态", + ) + + assert process_message.call_args.kwargs["is_channel_admin"] is True + + def test_ask_user_choice_message_is_not_recorded_to_message_history(): """Agent 询问用户意图工具发送的按钮消息不登记到消息表。""" _clear_messages() @@ -200,6 +269,7 @@ def test_agent_choice_callback_is_not_recorded_to_message_history(): source="telegram-test", userid="10001", username="tester", + is_channel_admin=False, original_message_id=123, original_chat_id="456", ) @@ -208,3 +278,4 @@ def test_agent_choice_callback_is_not_recorded_to_message_history(): record_user_message.assert_not_called() process_message.assert_called_once() + assert process_message.call_args.kwargs["is_channel_admin"] is False diff --git a/tests/test_agent_resource_flow_permissions.py b/tests/test_agent_resource_flow_permissions.py index 5d6c6144c..50c803492 100644 --- a/tests/test_agent_resource_flow_permissions.py +++ b/tests/test_agent_resource_flow_permissions.py @@ -9,6 +9,7 @@ from app.agent.tools.impl.edit_file import EditFileTool from app.agent.tools.impl.list_directory import ListDirectoryTool from app.agent.tools.impl.query_downloaders import QueryDownloadersTool from app.agent.tools.impl.query_sites import QuerySitesTool +from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool from app.agent.tools.impl.read_file import ReadFileTool from app.agent.tools.impl.write_file import WriteFileTool from app.agent.tools.manager import MoviePilotToolsManager @@ -357,3 +358,78 @@ def test_channel_agent_admin_user_id_does_not_bypass_user_lookup(): ) assert context["is_admin"] is False + + +def test_channel_agent_rejects_local_admin_username_without_trusted_principal(): + """外部显示名与本地管理员同名时,不得获得 Agent 管理员权限。""" + agent = MoviePilotAgent( + session_id="session-1", + user_id="10002", + channel=MessageChannel.Telegram.value, + source="telegram-main", + username="admin", + ) + agent.is_channel_admin = False + + with patch("app.agent.UserOper") as user_oper: + user_oper.return_value.async_get_by_name = AsyncMock( + return_value=SimpleNamespace(is_superuser=True) + ) + context = asyncio.run( + agent._build_tool_context(should_dispatch_reply=True) + ) + + assert context["is_admin"] is False + user_oper.return_value.async_get_by_name.assert_not_awaited() + + +def test_channel_agent_accepts_trusted_admin_principal_without_local_user(): + """宿主确认的渠道管理员应直接获得 Agent 管理员权限。""" + agent = MoviePilotAgent( + session_id="session-1", + user_id="10001", + channel=MessageChannel.Telegram.value, + source="telegram-main", + username="renamed-user", + ) + agent.is_channel_admin = True + + with patch("app.agent.UserOper") as user_oper: + context = asyncio.run( + agent._build_tool_context(should_dispatch_reply=True) + ) + + assert context["is_admin"] is True + user_oper.return_value.async_get_by_name.assert_not_called() + + +def test_tool_explicit_non_admin_context_does_not_fallback_to_channel_lookup(): + """Agent 已判定为非管理员时,工具不得通过旧权限查询重新授权。""" + tool = QuerySitesTool(session_id="session-1", user_id="10002") + tool.set_message_attr( + channel=MessageChannel.Telegram.value, + source="telegram-main", + username="admin", + ) + tool.set_agent_context({"is_admin": False}) + + with patch.object( + tool, + "_has_channel_admin_permission", + new=AsyncMock(return_value=True), + ) as has_channel_admin_permission: + result = asyncio.run(tool.is_admin_user()) + + assert result is False + has_channel_admin_permission.assert_not_awaited() + + +def test_admin_tool_rejects_explicit_non_admin_without_channel_context(): + """显式非管理员事实必须拒绝管理员工具,不能走无渠道兼容放行。""" + tool = QuerySystemSettingsTool(session_id="session-1", user_id="10002") + tool.set_agent_context({"is_admin": False}) + + result = asyncio.run(tool._check_permission()) + + assert result is not None + assert "没有执行此工具的权限" in result diff --git a/tests/test_agent_scheduled_tasks.py b/tests/test_agent_scheduled_tasks.py index 1a2c667bc..502995e14 100644 --- a/tests/test_agent_scheduled_tasks.py +++ b/tests/test_agent_scheduled_tasks.py @@ -631,6 +631,7 @@ async def test_cached_agent_clears_channel_for_background_task() -> None: channel="Telegram", source="telegram-test", username="admin", + is_channel_admin=True, original_chat_id="chat-123", ) agent.process = AsyncMock(return_value="完成") @@ -652,9 +653,42 @@ async def test_cached_agent_clears_channel_for_background_task() -> None: assert result == "完成" assert agent.channel is None assert agent.source is None + assert agent.is_channel_admin is None assert agent.original_chat_id is None +@pytest.mark.anyio +async def test_cached_agent_overwrites_channel_admin_with_explicit_false() -> None: + """复用会话 Agent 时,明确非管理员结论必须覆盖上一轮管理员身份。""" + manager = AgentManager() + agent = MoviePilotAgent( + session_id="channel-admin-cached-session", + user_id="user-1", + channel="Telegram", + source="telegram-test", + username="admin", + is_channel_admin=True, + ) + agent.process = AsyncMock(return_value="完成") + manager.active_agents[agent.session_id] = agent + task = _MessageTask( + session_id=agent.session_id, + user_id="user-2", + message="执行普通用户请求", + channel="Telegram", + source="telegram-test", + username="admin", + is_channel_admin=False, + ) + + result = await manager._process_message_internal(task) + + assert result == "完成" + assert agent.user_id == "user-2" + assert agent.username == "admin" + assert agent.is_channel_admin is False + + @pytest.mark.anyio async def test_background_agent_final_message_is_broadcast() -> None: """后台 Agent 的最终消息应清空渠道及渠道用户定位后广播。""" diff --git a/tests/test_message_processing_status.py b/tests/test_message_processing_status.py index 850e00d98..df21a1b4a 100644 --- a/tests/test_message_processing_status.py +++ b/tests/test_message_processing_status.py @@ -75,7 +75,9 @@ class TestMessageProcessingStatus(unittest.TestCase): module = SlackModule() with patch.object( - module, "get_config", return_value=SimpleNamespace(name="slack-main") + module, + "get_config", + return_value=SimpleNamespace(name="slack-main", config={}), ): message = module.message_parser( source="slack-main",