From 87b1caf3ffcf7464bf608a3d10cc2fd92201bde4 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 13 Aug 2026 23:46:56 +0800 Subject: [PATCH] fix(agent): grant channel owners admin access --- app/agent/tools/base.py | 60 +---- app/helper/agent.py | 68 ++++- app/modules/discord/__init__.py | 16 +- app/modules/feishu/__init__.py | 9 + app/modules/feishu/feishu.py | 28 +- app/modules/qqbot/__init__.py | 33 ++- app/modules/slack/__init__.py | 16 +- app/modules/synologychat/__init__.py | 16 +- app/modules/telegram/__init__.py | 33 ++- app/modules/vocechat/__init__.py | 14 +- app/modules/wechat/__init__.py | 31 ++- app/modules/wechat/wechatbot.py | 13 +- app/modules/wechatclawbot/__init__.py | 25 +- app/schemas/message.py | 2 +- tests/test_agent_channel_admin_identity.py | 249 +++++++++++++++++- tests/test_agent_resource_flow_permissions.py | 6 + 16 files changed, 499 insertions(+), 120 deletions(-) diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index e2ebf4b85..ebc1483cf 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -562,64 +562,20 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): user_id_str = str(self._user_id) if self._user_id else None - channel_type_map = { - MessageChannel.Telegram: "telegram", - MessageChannel.Discord: "discord", - MessageChannel.Wechat: "wechat", - MessageChannel.Feishu: "feishu", - MessageChannel.WechatClawBot: "wechatclawbot", - MessageChannel.Slack: "slack", - MessageChannel.VoceChat: "vocechat", - MessageChannel.SynologyChat: "synologychat", - MessageChannel.QQ: "qqbot", - } - - channel_type = None - for key, value in channel_type_map.items(): - if self._channel == key.value: - channel_type = value - break - - if not channel_type: + try: + channel = MessageChannel(self._channel) + except ValueError: return False - admin_key_map = { - "telegram": "TELEGRAM_ADMINS", - "discord": "DISCORD_ADMINS", - "wechat": "WECHAT_ADMINS", - "feishu": "FEISHU_ADMINS", - "wechatclawbot": "WECHATCLAWBOT_ADMINS", - "slack": "SLACK_ADMINS", - "vocechat": "VOCECHAT_ADMINS", - "synologychat": "SYNOLOGYCHAT_ADMINS", - "qqbot": "QQBOT_ADMINS", - } - - admin_key = admin_key_map.get(channel_type) - - # 各渠道主ID对应的配置键:渠道默认接收人通常是部署者本人, - # 即使未配置到管理员名单也应默认拥有管理员权限。 - primary_id_keys = { - "telegram": ("TELEGRAM_CHAT_ID",), - "feishu": ("FEISHU_OPEN_ID",), - "wechat": ("WECHAT_BOT_CHAT_ID",), - "wechatclawbot": ("WECHATCLAWBOT_DEFAULT_TARGET",), - "qqbot": ("QQ_OPENID",), - } - primary_keys = primary_id_keys.get(channel_type, ()) - try: configs = ServiceConfigHelper.get_notification_configs() for config in configs: if config.name == self._source and config.config: - if matches_channel_admin(config.config, admin_key, user_id_str): - return True - # 管理员名单遗漏主ID时按管理员处理 - if any( - matches_channel_admin(config.config, key, user_id_str) - for key in primary_keys - ): - return True + return matches_channel_admin( + channel, + config.config, + user_id_str, + ) except Exception as e: logger.error(f"检查权限失败: {summarize_error(e)}") diff --git a/app/helper/agent.py b/app/helper/agent.py index 40f24b2fd..5a183f4ce 100644 --- a/app/helper/agent.py +++ b/app/helper/agent.py @@ -1,31 +1,81 @@ from queue import Queue from threading import Lock -from typing import Optional, Union +from typing import Callable, Iterable, Optional, Union + +from app.schemas.types import MessageChannel _WEB_AGENT_EDIT_QUEUES: dict[str, list[Queue[dict]]] = {} _WEB_AGENT_EDIT_LOCK = Lock() +_ChannelAdminResolver = Callable[[Optional[dict]], Iterable[Union[str, int]]] +_CHANNEL_ADMIN_RESOLVERS: dict[str, _ChannelAdminResolver] = {} + + +def register_channel_admin_resolver( + channel: Union[MessageChannel, str], + resolver: _ChannelAdminResolver, +) -> None: + """ + 注册消息渠道的管理员主体 ID 解析器。 + + :param channel: 消息渠道 + :param resolver: 由渠道配置解析全部管理员主体 ID 的函数 + """ + channel_value = channel.value if isinstance(channel, MessageChannel) else str(channel) + _CHANNEL_ADMIN_RESOLVERS[channel_value] = resolver + + +def resolve_config_principal_ids( + config: Optional[dict], + *config_keys: str, +) -> set[str]: + """ + 从渠道自行声明的配置键中解析主体 ID。 + + :param config: 当前消息渠道配置 + :param config_keys: 由渠道模块维护的主体 ID 配置键 + :return: 去空白后的主体 ID 集合 + """ + principal_ids = set() + for config_key in config_keys: + principal_ids.update( + item.strip() + for item in str((config or {}).get(config_key) or "").split(",") + if item.strip() + ) + return principal_ids def matches_channel_admin( + channel: Union[MessageChannel, str], 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() + """ + 按渠道配置中的稳定主体 ID 判断管理员身份。 + + :param channel: 消息渠道 + :param config: 当前消息渠道配置 + :param principal_ids: 消息渠道提供的稳定用户主体 ID + :return: 任一用户主体 ID 命中渠道注册的管理员集合时返回 True + """ + channel_value = channel.value if isinstance(channel, MessageChannel) else str(channel) + resolver = _CHANNEL_ADMIN_RESOLVERS.get(channel_value) + if not resolver: + return False + authorized_ids = { + str(principal_id).strip() + for principal_id in resolver(config) + if principal_id is not None and str(principal_id).strip() } - if not admins: + if not authorized_ids: 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)) + return bool(authorized_ids.intersection(candidates)) def normalize_web_agent_button_rows(buttons: Optional[list[list[dict]]]) -> list[list[dict]]: diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index 93947d54e..634e21f9e 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -5,7 +5,11 @@ 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.helper.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.schemas import ( @@ -26,6 +30,12 @@ except Exception as err: # ImportError or other load issues logger.error(f"Discord 模块未加载,缺少依赖或初始化错误:{err}") +register_channel_admin_resolver( + MessageChannel.Discord, + lambda config: resolve_config_principal_ids(config, "DISCORD_ADMINS"), +) + + class DiscordModule(_ModuleBase, _MessageBase[Discord]): _IMAGE_SUFFIXES = ( ".png", @@ -209,7 +219,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): userid=userid, username=username, is_channel_admin=matches_channel_admin( - client_config.config, "DISCORD_ADMINS", userid + MessageChannel.Discord, client_config.config, userid ), text=f"CALLBACK:{callback_data}", is_callback=True, @@ -243,7 +253,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): userid=userid, username=username, is_channel_admin=matches_channel_admin( - client_config.config, "DISCORD_ADMINS", userid + MessageChannel.Discord, client_config.config, userid ), text=text, chat_id=str(chat_id) if chat_id else None, diff --git a/app/modules/feishu/__init__.py b/app/modules/feishu/__init__.py index bc670d0ff..fcd9a85c7 100644 --- a/app/modules/feishu/__init__.py +++ b/app/modules/feishu/__init__.py @@ -1,6 +1,7 @@ from typing import Any, List, Optional, Tuple, Union from app.core.context import Context, MediaInfo +from app.helper.agent import register_channel_admin_resolver, resolve_config_principal_ids from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.feishu.feishu import Feishu @@ -8,6 +9,14 @@ from app.schemas import CommingMessage, MessageChannel, MessageResponse, Notific from app.schemas.types import ModuleType +register_channel_admin_resolver( + MessageChannel.Feishu, + lambda config: resolve_config_principal_ids( + config, "FEISHU_ADMINS", "FEISHU_OPEN_ID" + ), +) + + class FeishuModule(_ModuleBase, _MessageBase[Feishu]): def init_module(self) -> None: super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu) diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index 0d6536e2a..66682fe7f 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -115,12 +115,14 @@ class Feishu: """判断飞书命令或命令型按钮回调是否应因非管理员身份被拒绝。""" if not self._admins: return False - candidates = [ - str(user_id).strip() - for user_id in user_ids - if user_id is not None and str(user_id).strip() - ] - return not any(candidate in self._admins for candidate in candidates) + return not matches_channel_admin( + MessageChannel.Feishu, + { + "FEISHU_ADMINS": ",".join(self._admins), + "FEISHU_OPEN_ID": self._default_open_id, + }, + *user_ids, + ) def _build_api_client(self) -> lark.Client: """构建飞书 OpenAPI client,用于发送和编辑消息。""" @@ -689,8 +691,11 @@ class Feishu: userid=userid, username=username, is_channel_admin=matches_channel_admin( - {"FEISHU_ADMINS": ",".join(self._admins)}, - "FEISHU_ADMINS", + MessageChannel.Feishu, + { + "FEISHU_ADMINS": ",".join(self._admins), + "FEISHU_OPEN_ID": self._default_open_id, + }, open_id, user_id, ), @@ -732,8 +737,11 @@ class Feishu: userid=userid, username=username, is_channel_admin=matches_channel_admin( - {"FEISHU_ADMINS": ",".join(self._admins)}, - "FEISHU_ADMINS", + MessageChannel.Feishu, + { + "FEISHU_ADMINS": ",".join(self._admins), + "FEISHU_OPEN_ID": self._default_open_id, + }, open_id, user_id, ), diff --git a/app/modules/qqbot/__init__.py b/app/modules/qqbot/__init__.py index c66194286..27c5b92c9 100644 --- a/app/modules/qqbot/__init__.py +++ b/app/modules/qqbot/__init__.py @@ -9,7 +9,11 @@ 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.helper.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.qqbot.qqbot import QQBot @@ -18,6 +22,14 @@ from app.schemas.types import ModuleType from app.utils.http import RequestUtils +register_channel_admin_resolver( + MessageChannel.QQ, + lambda config: resolve_config_principal_ids( + config, "QQBOT_ADMINS", "QQ_OPENID" + ), +) + + class QQBotModule(_ModuleBase, _MessageBase[QQBot]): """QQ Bot 通知模块""" @@ -108,12 +120,11 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]): admins = cls._get_admins(config) if not admins: return False - candidates = [ - str(user_id).strip() - for user_id in user_ids - if user_id is not None and str(user_id).strip() - ] - return not any(candidate in admins for candidate in candidates) + return not matches_channel_admin( + MessageChannel.QQ, + config, + *user_ids, + ) @staticmethod def _send_admin_denied( @@ -176,7 +187,9 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]): userid=user_openid, username=user_openid, is_channel_admin=matches_channel_admin( - client_config.config, "QQBOT_ADMINS", user_openid + MessageChannel.QQ, + client_config.config, + user_openid, ), text=content, images=images, @@ -205,7 +218,9 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]): userid=userid, username=member_openid or group_openid, is_channel_admin=matches_channel_admin( - client_config.config, "QQBOT_ADMINS", member_openid + MessageChannel.QQ, + client_config.config, + member_openid, ), text=content, images=images, diff --git a/app/modules/slack/__init__.py b/app/modules/slack/__init__.py index e3609cb6e..5ab7bf21f 100644 --- a/app/modules/slack/__init__.py +++ b/app/modules/slack/__init__.py @@ -6,7 +6,11 @@ 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.helper.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.slack.slack import Slack @@ -21,6 +25,12 @@ from app.schemas.types import ChainEventType, ModuleType from app.utils.structures import DictUtils +register_channel_admin_resolver( + MessageChannel.Slack, + lambda config: resolve_config_principal_ids(config, "SLACK_ADMINS"), +) + + class SlackModule(_ModuleBase, _MessageBase[Slack]): PROCESSING_REACTION = "eyes" _AUDIO_SUFFIXES = ( @@ -321,7 +331,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): userid=userid, username=username, is_channel_admin=matches_channel_admin( - client_config.config, "SLACK_ADMINS", userid + MessageChannel.Slack, client_config.config, userid ), text=text, is_callback=True, @@ -378,7 +388,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): userid=userid, username=username, is_channel_admin=matches_channel_admin( - client_config.config, "SLACK_ADMINS", userid + MessageChannel.Slack, client_config.config, userid ), text=text, message_id=message_id, diff --git a/app/modules/synologychat/__init__.py b/app/modules/synologychat/__init__.py index c2c88a234..65b4dd789 100644 --- a/app/modules/synologychat/__init__.py +++ b/app/modules/synologychat/__init__.py @@ -3,7 +3,11 @@ 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.helper.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.synologychat.synologychat import SynologyChat @@ -12,6 +16,12 @@ from app.schemas.types import ModuleType from app.utils.http import RequestUtils +register_channel_admin_resolver( + MessageChannel.SynologyChat, + lambda config: resolve_config_principal_ids(config, "SYNOLOGYCHAT_ADMINS"), +) + + class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]): _IMAGE_SUFFIXES = ( ".png", @@ -182,7 +192,9 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]): return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name, userid=user_id, username=user_name, is_channel_admin=matches_channel_admin( - client_config.config, "SYNOLOGYCHAT_ADMINS", user_id + MessageChannel.SynologyChat, + client_config.config, + user_id, ), text=text or "", images=images, audio_refs=audio_refs, files=files) except Exception as err: diff --git a/app/modules/telegram/__init__.py b/app/modules/telegram/__init__.py index 3ff3fc73d..723ae166d 100644 --- a/app/modules/telegram/__init__.py +++ b/app/modules/telegram/__init__.py @@ -5,7 +5,11 @@ 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.helper.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.telegram.telegram import Telegram @@ -21,6 +25,14 @@ from app.schemas.types import ModuleType, ChainEventType from app.utils.structures import DictUtils +register_channel_admin_resolver( + MessageChannel.Telegram, + lambda config: resolve_config_principal_ids( + config, "TELEGRAM_ADMINS", "TELEGRAM_CHAT_ID" + ), +) + + class TelegramModule(_ModuleBase, _MessageBase[Telegram]): """ Telegram 通知模块,负责模块生命周期、消息解析和通知发送。 @@ -112,12 +124,11 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): admins = cls._get_admins(config) if not admins: return False - candidates = [ - str(user_id).strip() - for user_id in user_ids - if user_id is not None and str(user_id).strip() - ] - return not any(candidate in admins for candidate in candidates) + return not matches_channel_admin( + MessageChannel.Telegram, + config, + *user_ids, + ) def message_parser( self, source: str, body: Any, form: Any, args: Any @@ -237,7 +248,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): userid=user_id, username=user_name, is_channel_admin=matches_channel_admin( - client_config.config, "TELEGRAM_ADMINS", user_id + MessageChannel.Telegram, + client_config.config, + user_id, ), text=callback_text, is_callback=True, @@ -316,7 +329,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): userid=user_id, username=user_name, is_channel_admin=matches_channel_admin( - client_config.config, "TELEGRAM_ADMINS", user_id + MessageChannel.Telegram, + client_config.config, + user_id, ), text=cleaned_text, message_id=message_id, diff --git a/app/modules/vocechat/__init__.py b/app/modules/vocechat/__init__.py index 823df84f6..55d2c0017 100644 --- a/app/modules/vocechat/__init__.py +++ b/app/modules/vocechat/__init__.py @@ -3,7 +3,11 @@ 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.helper.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.vocechat.vocechat import VoceChat @@ -11,6 +15,12 @@ from app.schemas import MessageChannel, CommingMessage, Notification from app.schemas.types import ModuleType +register_channel_admin_resolver( + MessageChannel.VoceChat, + lambda config: resolve_config_principal_ids(config, "VOCECHAT_ADMINS"), +) + + class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]): _IMAGE_SUFFIXES = ( ".png", @@ -208,7 +218,7 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]): return CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name, userid=userid, username=userid, is_channel_admin=matches_channel_admin( - client_config.config, "VOCECHAT_ADMINS", + MessageChannel.VoceChat, client_config.config, from_uid, actor_userid, ), text=text or "", images=images, audio_refs=audio_refs, files=files) diff --git a/app/modules/wechat/__init__.py b/app/modules/wechat/__init__.py index 74298c6c7..5b64fa9e5 100644 --- a/app/modules/wechat/__init__.py +++ b/app/modules/wechat/__init__.py @@ -7,7 +7,11 @@ 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.helper.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt @@ -19,6 +23,17 @@ from app.utils.dom import DomUtils from app.utils.structures import DictUtils +def _resolve_wechat_admin_ids(config: Optional[dict]) -> set[str]: + """解析企业微信管理员及机器人模式下的主用户 ID。""" + config_keys = ["WECHAT_ADMINS"] + if (config or {}).get("WECHAT_MODE", "app") == "bot": + config_keys.append("WECHAT_BOT_CHAT_ID") + return resolve_config_principal_ids(config, *config_keys) + + +register_channel_admin_resolver(MessageChannel.Wechat, _resolve_wechat_admin_ids) + + class WechatModule(_ModuleBase, _MessageBase[WeChat]): def init_module(self) -> None: @@ -88,7 +103,11 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]): admins = cls._get_admins(config) if not admins: return False - return str(user_id or "").strip() not in admins + return not matches_channel_admin( + MessageChannel.Wechat, + config, + user_id, + ) @classmethod def _create_client(cls, conf): @@ -253,7 +272,9 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]): return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name, userid=user_id, username=user_id, is_channel_admin=matches_channel_admin( - client_config.config, "WECHAT_ADMINS", user_id + MessageChannel.Wechat, + client_config.config, + user_id, ), text=content or "", images=images, audio_refs=audio_refs, files=files) except Exception as err: @@ -325,7 +346,9 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]): userid=sender, username=sender, is_channel_admin=matches_channel_admin( - client_config.config, "WECHAT_ADMINS", sender + MessageChannel.Wechat, + client_config.config, + sender, ), text=text or "", images=images, diff --git a/app/modules/wechat/wechatbot.py b/app/modules/wechat/wechatbot.py index 1858f5f56..0e9c87909 100644 --- a/app/modules/wechat/wechatbot.py +++ b/app/modules/wechat/wechatbot.py @@ -15,8 +15,10 @@ from app.core.cache import FileCache from app.core.config import settings from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo +from app.helper.agent import matches_channel_admin from app.log import logger from app.schemas import CommingMessage +from app.schemas.types import MessageChannel from app.utils.http import RequestUtils from app.utils.string import StringUtils @@ -489,7 +491,16 @@ class WeChatBot: self._remember_target(sender) - if text and text.startswith("/") and self._admins and sender not in self._admins: + is_channel_admin = matches_channel_admin( + MessageChannel.Wechat, + { + "WECHAT_ADMINS": ",".join(self._admins), + "WECHAT_BOT_CHAT_ID": getattr(self, "_default_chat_id", None), + "WECHAT_MODE": "bot", + }, + sender, + ) + if text and text.startswith("/") and self._admins and not is_channel_admin: self.send_msg(title="只有管理员才有权限执行此命令", userid=sender) return diff --git a/app/modules/wechatclawbot/__init__.py b/app/modules/wechatclawbot/__init__.py index 6f322108d..5705395df 100644 --- a/app/modules/wechatclawbot/__init__.py +++ b/app/modules/wechatclawbot/__init__.py @@ -3,7 +3,11 @@ 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.helper.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) from app.log import logger from app.modules import _MessageBase, _ModuleBase from app.modules.wechatclawbot.wechatclawbot import WechatClawBot @@ -11,6 +15,14 @@ from app.schemas import CommingMessage, Notification from app.schemas.types import MessageChannel, ModuleType +register_channel_admin_resolver( + MessageChannel.WechatClawBot, + lambda config: resolve_config_principal_ids( + config, "WECHATCLAWBOT_ADMINS", "WECHATCLAWBOT_DEFAULT_TARGET" + ), +) + + class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]): def __init__(self): """初始化模块级去重缓存,拦截 iLink 偶发的重复回放消息。""" @@ -182,7 +194,12 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]): ] callback_data = text[9:].strip() if text.startswith("CALLBACK:") else "" is_admin_command = text.startswith("/") or callback_data.startswith("/") - if is_admin_command and admins and user_id not in admins: + is_channel_admin = matches_channel_admin( + MessageChannel.WechatClawBot, + client_config.config, + user_id, + ) + if is_admin_command and admins and not is_channel_admin: client = self.get_instance(client_config.name) if client: client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id) @@ -199,9 +216,7 @@ 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 - ), + is_channel_admin=is_channel_admin, 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 e5a7b8ae5..601bca0ef 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -168,7 +168,7 @@ class CommingMessage(BaseModel): userid: Optional[Union[str, int]] = None # 用户名称 username: Optional[Union[str, int]] = None - # 渠道适配器依据稳定用户 ID 与当前实例管理员名单生成的授权事实 + # 渠道适配器依据稳定用户 ID、管理员名单及渠道主用户 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 index 2a1481a00..63ab83c87 100644 --- a/tests/test_agent_channel_admin_identity.py +++ b/tests/test_agent_channel_admin_identity.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch import pytest -from app.helper.agent import matches_channel_admin +from app.helper.agent import matches_channel_admin, resolve_config_principal_ids from app.modules.discord import DiscordModule from app.modules.feishu.feishu import Feishu from app.modules.qqbot import QQBotModule @@ -13,7 +13,9 @@ 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.wechat.wechatbot import WeChatBot from app.modules.wechatclawbot import WechatClawBotModule +from app.schemas.types import MessageChannel def _parse_module_message(module, *, config: dict, body, client=None, form=None): @@ -33,19 +35,99 @@ def _parse_module_message(module, *, config: dict, body, client=None, form=None) @pytest.mark.parametrize( - ("config", "principal_ids", "expected"), + ("config", "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), + ({"ADMINS": " user-1, 42 "}, {"user-1", "42"}), + ({"ADMINS": ""}, set()), + ({}, set()), + (None, set()), ], ) -def test_matches_channel_admin_uses_nonempty_stable_principal_set( - config, principal_ids, expected +def test_resolve_config_principal_ids_uses_nonempty_stable_values(config, expected): + """渠道模块声明的配置值应统一转为非空字符串 ID。""" + assert resolve_config_principal_ids(config, "ADMINS") == expected + + +@pytest.mark.parametrize( + ("channel", "config", "principal_ids", "expected"), + [ + ( + MessageChannel.Telegram, + {"TELEGRAM_ADMINS": "other", "TELEGRAM_CHAT_ID": "10001"}, + (10001,), + True, + ), + ( + MessageChannel.Feishu, + {"FEISHU_ADMINS": "other", "FEISHU_OPEN_ID": "ou_owner"}, + ("ou_owner",), + True, + ), + ( + MessageChannel.Wechat, + { + "WECHAT_MODE": "bot", + "WECHAT_ADMINS": "other", + "WECHAT_BOT_CHAT_ID": "wx_owner", + }, + ("wx_owner",), + True, + ), + ( + MessageChannel.Wechat, + { + "WECHAT_MODE": "app", + "WECHAT_ADMINS": "other", + "WECHAT_BOT_CHAT_ID": "stale_owner", + }, + ("stale_owner",), + False, + ), + ( + MessageChannel.WechatClawBot, + { + "WECHATCLAWBOT_ADMINS": "other", + "WECHATCLAWBOT_DEFAULT_TARGET": "wxid_owner", + }, + ("wxid_owner",), + True, + ), + ( + MessageChannel.QQ, + {"QQBOT_ADMINS": "other", "QQ_OPENID": "qq_owner"}, + ("qq_owner",), + True, + ), + ( + MessageChannel.Telegram, + {"TELEGRAM_ADMINS": "other", "TELEGRAM_CHAT_ID": "-10001"}, + (10001,), + False, + ), + ( + MessageChannel.Feishu, + {"FEISHU_ADMINS": "other", "FEISHU_CHAT_ID": "oc_group"}, + ("ou_user",), + False, + ), + ( + MessageChannel.QQ, + {"QQBOT_ADMINS": "other", "QQ_GROUP_OPENID": "qq_group"}, + ("qq_member",), + False, + ), + ], +) +def test_matches_channel_admin_includes_only_primary_user_ids( + channel, config, principal_ids, expected ): - assert matches_channel_admin(config, "ADMINS", *principal_ids) is expected + """渠道主用户 ID 默认授权,但群组或频道目标不能授权其成员。""" + assert matches_channel_admin(channel, config, *principal_ids) is expected + + +def test_matches_channel_admin_rejects_unregistered_channel(): + """未注册管理员解析器的渠道不能获得管理员权限。""" + assert not matches_channel_admin("unregistered", {"ADMINS": "owner"}, "owner") @pytest.mark.parametrize("message_kind", ["message", "callback"]) @@ -113,6 +195,50 @@ def test_telegram_uses_stable_user_id_for_admin(message_kind): assert message.is_channel_admin is True +def test_telegram_primary_user_id_is_admin_without_duplicate_admin_entry(): + """Telegram 主用户 ID 无需重复加入管理员名单。""" + module = TelegramModule() + client = SimpleNamespace(bot_username=None, send_msg=Mock()) + message = _parse_module_message( + module, + config={ + "TELEGRAM_CHAT_ID": "10001", + "TELEGRAM_ADMINS": "10002", + }, + body=json.dumps( + { + "message_id": 10, + "from": {"id": 10001, "username": "owner"}, + "chat": {"id": 10001}, + "text": "/sites", + } + ), + client=client, + ) + + assert message.is_channel_admin is True + client.send_msg.assert_not_called() + + +def test_telegram_group_chat_id_does_not_authorize_group_member(): + """Telegram 群组 Chat ID 不能使群内发送者默认成为管理员。""" + message = _parse_module_message( + TelegramModule(), + config={"TELEGRAM_CHAT_ID": "-10001", "TELEGRAM_ADMINS": "10002"}, + body=json.dumps( + { + "message_id": 10, + "from": {"id": 10001, "username": "member"}, + "chat": {"id": -10001}, + "text": "hello", + } + ), + client=SimpleNamespace(bot_username=None), + ) + + assert message.is_channel_admin is False + + def test_telegram_slash_does_not_accept_admin_display_username(): """Telegram 斜杠命令不得把可修改的 username 当作管理员 ID。""" module = TelegramModule() @@ -296,6 +422,29 @@ def test_feishu_message_and_card_callback_accept_open_id_or_user_id(payload, adm assert message.is_channel_admin is True +def test_feishu_default_open_id_is_admin_without_duplicate_admin_entry(): + """飞书默认用户 Open ID 无需重复加入管理员名单。""" + 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_OPEN_ID="ou_owner", + FEISHU_ADMINS="ou_other", + name="feishu-test", + ) + message = client.parse_message( + { + "text": "/sites", + "sender": {"open_id": "ou_owner", "name": "owner"}, + } + ) + + assert message.is_channel_admin is True + + @pytest.mark.parametrize( ("user_id", "username", "expected"), [("wxid_admin", "renamed-user", True), ("wxid_user", "admin-name", False)], @@ -316,6 +465,25 @@ def test_wechatclawbot_uses_channel_user_id_not_username(user_id, username, expe assert message.is_channel_admin is expected +def test_wechatclawbot_primary_user_id_is_admin_without_duplicate_admin_entry(): + """微信 ClawBot 默认用户 ID 无需重复加入管理员名单。""" + message = _parse_module_message( + WechatClawBotModule(), + config={ + "WECHATCLAWBOT_DEFAULT_TARGET": "wxid_owner", + "WECHATCLAWBOT_ADMINS": "wxid_other", + }, + body={ + "__channel__": "wechatclawbot", + "userid": "wxid_owner", + "username": "owner", + "text": "/sites", + }, + ) + + assert message.is_channel_admin is True + + @pytest.mark.parametrize( ("sender", "admins", "expected"), [("wechat-admin", "wechat-admin", True), ("wechat-user", "display-admin", False)], @@ -339,6 +507,52 @@ def test_wechat_bot_uses_sender_userid(sender, admins, expected): assert message.is_channel_admin is expected +def test_wechat_bot_primary_user_id_is_admin_without_duplicate_admin_entry(): + """企业微信机器人默认用户 ID 无需重复加入管理员名单。""" + message = _parse_module_message( + WechatModule(), + config={ + "WECHAT_MODE": "bot", + "WECHAT_BOT_CHAT_ID": "wechat-owner", + "WECHAT_ADMINS": "wechat-other", + }, + body=json.dumps( + { + "body": { + "from": {"userid": "wechat-owner"}, + "msgtype": "text", + "text": {"content": "/sites"}, + } + } + ), + ) + + assert message.is_channel_admin is True + + +def test_wechat_bot_client_allows_primary_user_command(): + """企业微信机器人客户端不得在转发前拦截主用户 ID。""" + bot = WeChatBot.__new__(WeChatBot) + bot._config_name = "wechat-bot-test" + bot._admins = ["wechat-other"] + bot._default_chat_id = "wechat-owner" + bot.send_msg = Mock() + bot._remember_target = Mock() + bot._forward_to_message_chain = Mock() + payload = { + "body": { + "from": {"userid": "wechat-owner"}, + "msgtype": "text", + "text": {"content": "/sites"}, + } + } + + bot._handle_callback_message(payload) + + bot.send_msg.assert_not_called() + bot._forward_to_message_chain.assert_called_once_with(payload) + + def test_qq_c2c_uses_user_openid_for_admin(): message = _parse_module_message( QQBotModule(), @@ -354,6 +568,21 @@ def test_qq_c2c_uses_user_openid_for_admin(): assert message.is_channel_admin is True +def test_qq_primary_user_openid_is_admin_without_duplicate_admin_entry(): + """QQ 默认用户 OpenID 无需重复加入管理员名单。""" + message = _parse_module_message( + QQBotModule(), + config={"QQ_OPENID": "qq-owner", "QQBOT_ADMINS": "qq-other"}, + body={ + "type": "C2C_MESSAGE_CREATE", + "content": "/sites", + "author": {"user_openid": "qq-owner"}, + }, + ) + + assert message.is_channel_admin is True + + @pytest.mark.parametrize( ("admins", "expected"), [("member-admin", True), ("group:group-admin", False), ("group-admin", False)], diff --git a/tests/test_agent_resource_flow_permissions.py b/tests/test_agent_resource_flow_permissions.py index 15db7cea9..1a61a03b2 100644 --- a/tests/test_agent_resource_flow_permissions.py +++ b/tests/test_agent_resource_flow_permissions.py @@ -15,9 +15,15 @@ from app.agent.tools.impl.write_file import WriteFileTool from app.agent.tools.manager import MoviePilotToolsManager from app.agent import MoviePilotAgent from app.core.config import settings +from app.modules.feishu import FeishuModule +from app.modules.telegram import TelegramModule from app.schemas.types import MessageChannel +# 渠道模块在导入时注册管理员解析器,权限回查测试需显式加载对应模块。 +_REGISTERED_CHANNEL_MODULES = (FeishuModule, TelegramModule) + + def test_non_admin_manager_exposes_resource_flow_helper_tools(): """普通用户应能看到搜索、订阅、下载流程所需的辅助工具。""" site_tool = QuerySitesTool(session_id="session-1", user_id="10001")