fix(agent): grant channel owners admin access

This commit is contained in:
jxxghp
2026-08-14 00:01:37 +08:00
parent 73df2aa331
commit 87b1caf3ff
16 changed files with 499 additions and 120 deletions
+8 -52
View File
@@ -562,64 +562,20 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
user_id_str = str(self._user_id) if self._user_id else None user_id_str = str(self._user_id) if self._user_id else None
channel_type_map = { try:
MessageChannel.Telegram: "telegram", channel = MessageChannel(self._channel)
MessageChannel.Discord: "discord", except ValueError:
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:
return False 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: try:
configs = ServiceConfigHelper.get_notification_configs() configs = ServiceConfigHelper.get_notification_configs()
for config in configs: for config in configs:
if config.name == self._source and config.config: if config.name == self._source and config.config:
if matches_channel_admin(config.config, admin_key, user_id_str): return matches_channel_admin(
return True channel,
# 管理员名单遗漏主ID时按管理员处理 config.config,
if any( user_id_str,
matches_channel_admin(config.config, key, user_id_str) )
for key in primary_keys
):
return True
except Exception as e: except Exception as e:
logger.error(f"检查权限失败: {summarize_error(e)}") logger.error(f"检查权限失败: {summarize_error(e)}")
+59 -9
View File
@@ -1,31 +1,81 @@
from queue import Queue from queue import Queue
from threading import Lock 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_QUEUES: dict[str, list[Queue[dict]]] = {}
_WEB_AGENT_EDIT_LOCK = Lock() _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( def matches_channel_admin(
channel: Union[MessageChannel, str],
config: Optional[dict], config: Optional[dict],
admin_key: str,
*principal_ids: Optional[Union[str, int]], *principal_ids: Optional[Union[str, int]],
) -> bool: ) -> bool:
"""按渠道配置中的稳定主体 ID 判断管理员身份。""" """
admins = { 按渠道配置中的稳定主体 ID 判断管理员身份。
item.strip()
for item in str((config or {}).get(admin_key) or "").split(",") :param channel: 消息渠道
if item.strip() :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 return False
candidates = { candidates = {
str(principal_id).strip() str(principal_id).strip()
for principal_id in principal_ids for principal_id in principal_ids
if principal_id is not None and str(principal_id).strip() 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]]: def normalize_web_agent_button_rows(buttons: Optional[list[list[dict]]]) -> list[list[dict]]:
+13 -3
View File
@@ -5,7 +5,11 @@ from urllib.parse import quote, unquote
from app.core.context import MediaInfo, Context from app.core.context import MediaInfo, Context
from app.core.event import eventmanager 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.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules import _ModuleBase, _MessageBase
from app.schemas import ( from app.schemas import (
@@ -26,6 +30,12 @@ except Exception as err: # ImportError or other load issues
logger.error(f"Discord 模块未加载,缺少依赖或初始化错误:{err}") 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]): class DiscordModule(_ModuleBase, _MessageBase[Discord]):
_IMAGE_SUFFIXES = ( _IMAGE_SUFFIXES = (
".png", ".png",
@@ -209,7 +219,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
userid=userid, userid=userid,
username=username, username=username,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "DISCORD_ADMINS", userid MessageChannel.Discord, client_config.config, userid
), ),
text=f"CALLBACK:{callback_data}", text=f"CALLBACK:{callback_data}",
is_callback=True, is_callback=True,
@@ -243,7 +253,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
userid=userid, userid=userid,
username=username, username=username,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "DISCORD_ADMINS", userid MessageChannel.Discord, client_config.config, userid
), ),
text=text, text=text,
chat_id=str(chat_id) if chat_id else None, chat_id=str(chat_id) if chat_id else None,
+9
View File
@@ -1,6 +1,7 @@
from typing import Any, List, Optional, Tuple, Union from typing import Any, List, Optional, Tuple, Union
from app.core.context import Context, MediaInfo 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.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules import _ModuleBase, _MessageBase
from app.modules.feishu.feishu import Feishu 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 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]): class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
def init_module(self) -> None: def init_module(self) -> None:
super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu) super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu)
+18 -10
View File
@@ -115,12 +115,14 @@ class Feishu:
"""判断飞书命令或命令型按钮回调是否应因非管理员身份被拒绝。""" """判断飞书命令或命令型按钮回调是否应因非管理员身份被拒绝。"""
if not self._admins: if not self._admins:
return False return False
candidates = [ return not matches_channel_admin(
str(user_id).strip() MessageChannel.Feishu,
for user_id in user_ids {
if user_id is not None and str(user_id).strip() "FEISHU_ADMINS": ",".join(self._admins),
] "FEISHU_OPEN_ID": self._default_open_id,
return not any(candidate in self._admins for candidate in candidates) },
*user_ids,
)
def _build_api_client(self) -> lark.Client: def _build_api_client(self) -> lark.Client:
"""构建飞书 OpenAPI client,用于发送和编辑消息。""" """构建飞书 OpenAPI client,用于发送和编辑消息。"""
@@ -689,8 +691,11 @@ class Feishu:
userid=userid, userid=userid,
username=username, username=username,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
{"FEISHU_ADMINS": ",".join(self._admins)}, MessageChannel.Feishu,
"FEISHU_ADMINS", {
"FEISHU_ADMINS": ",".join(self._admins),
"FEISHU_OPEN_ID": self._default_open_id,
},
open_id, open_id,
user_id, user_id,
), ),
@@ -732,8 +737,11 @@ class Feishu:
userid=userid, userid=userid,
username=username, username=username,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
{"FEISHU_ADMINS": ",".join(self._admins)}, MessageChannel.Feishu,
"FEISHU_ADMINS", {
"FEISHU_ADMINS": ",".join(self._admins),
"FEISHU_OPEN_ID": self._default_open_id,
},
open_id, open_id,
user_id, user_id,
), ),
+24 -9
View File
@@ -9,7 +9,11 @@ from urllib.parse import quote, unquote
from typing import Optional, List, Tuple, Union, Any from typing import Optional, List, Tuple, Union, Any
from app.core.context import MediaInfo, Context 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.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules import _ModuleBase, _MessageBase
from app.modules.qqbot.qqbot import QQBot from app.modules.qqbot.qqbot import QQBot
@@ -18,6 +22,14 @@ from app.schemas.types import ModuleType
from app.utils.http import RequestUtils 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]): class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
"""QQ Bot 通知模块""" """QQ Bot 通知模块"""
@@ -108,12 +120,11 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
admins = cls._get_admins(config) admins = cls._get_admins(config)
if not admins: if not admins:
return False return False
candidates = [ return not matches_channel_admin(
str(user_id).strip() MessageChannel.QQ,
for user_id in user_ids config,
if user_id is not None and str(user_id).strip() *user_ids,
] )
return not any(candidate in admins for candidate in candidates)
@staticmethod @staticmethod
def _send_admin_denied( def _send_admin_denied(
@@ -176,7 +187,9 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
userid=user_openid, userid=user_openid,
username=user_openid, username=user_openid,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "QQBOT_ADMINS", user_openid MessageChannel.QQ,
client_config.config,
user_openid,
), ),
text=content, text=content,
images=images, images=images,
@@ -205,7 +218,9 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
userid=userid, userid=userid,
username=member_openid or group_openid, username=member_openid or group_openid,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "QQBOT_ADMINS", member_openid MessageChannel.QQ,
client_config.config,
member_openid,
), ),
text=content, text=content,
images=images, images=images,
+13 -3
View File
@@ -6,7 +6,11 @@ from urllib.parse import quote, unquote
from app.core.context import MediaInfo, Context from app.core.context import MediaInfo, Context
from app.core.event import eventmanager 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.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules import _ModuleBase, _MessageBase
from app.modules.slack.slack import Slack from app.modules.slack.slack import Slack
@@ -21,6 +25,12 @@ from app.schemas.types import ChainEventType, ModuleType
from app.utils.structures import DictUtils 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]): class SlackModule(_ModuleBase, _MessageBase[Slack]):
PROCESSING_REACTION = "eyes" PROCESSING_REACTION = "eyes"
_AUDIO_SUFFIXES = ( _AUDIO_SUFFIXES = (
@@ -321,7 +331,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
userid=userid, userid=userid,
username=username, username=username,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "SLACK_ADMINS", userid MessageChannel.Slack, client_config.config, userid
), ),
text=text, text=text,
is_callback=True, is_callback=True,
@@ -378,7 +388,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
userid=userid, userid=userid,
username=username, username=username,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "SLACK_ADMINS", userid MessageChannel.Slack, client_config.config, userid
), ),
text=text, text=text,
message_id=message_id, message_id=message_id,
+14 -2
View File
@@ -3,7 +3,11 @@ from typing import Optional, Union, List, Tuple, Any
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
from app.core.context import MediaInfo, Context 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.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules import _ModuleBase, _MessageBase
from app.modules.synologychat.synologychat import SynologyChat from app.modules.synologychat.synologychat import SynologyChat
@@ -12,6 +16,12 @@ from app.schemas.types import ModuleType
from app.utils.http import RequestUtils 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]): class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
_IMAGE_SUFFIXES = ( _IMAGE_SUFFIXES = (
".png", ".png",
@@ -182,7 +192,9 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name, return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name,
userid=user_id, username=user_name, userid=user_id, username=user_name,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "SYNOLOGYCHAT_ADMINS", user_id MessageChannel.SynologyChat,
client_config.config,
user_id,
), text=text or "", ), text=text or "",
images=images, audio_refs=audio_refs, files=files) images=images, audio_refs=audio_refs, files=files)
except Exception as err: except Exception as err:
+24 -9
View File
@@ -5,7 +5,11 @@ from typing import Dict, Optional, Union, List, Tuple, Any
from app.core.context import MediaInfo, Context from app.core.context import MediaInfo, Context
from app.core.event import eventmanager 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.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules import _ModuleBase, _MessageBase
from app.modules.telegram.telegram import Telegram from app.modules.telegram.telegram import Telegram
@@ -21,6 +25,14 @@ from app.schemas.types import ModuleType, ChainEventType
from app.utils.structures import DictUtils 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]): class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
""" """
Telegram 通知模块负责模块生命周期消息解析和通知发送 Telegram 通知模块负责模块生命周期消息解析和通知发送
@@ -112,12 +124,11 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
admins = cls._get_admins(config) admins = cls._get_admins(config)
if not admins: if not admins:
return False return False
candidates = [ return not matches_channel_admin(
str(user_id).strip() MessageChannel.Telegram,
for user_id in user_ids config,
if user_id is not None and str(user_id).strip() *user_ids,
] )
return not any(candidate in admins for candidate in candidates)
def message_parser( def message_parser(
self, source: str, body: Any, form: Any, args: Any self, source: str, body: Any, form: Any, args: Any
@@ -237,7 +248,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
userid=user_id, userid=user_id,
username=user_name, username=user_name,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "TELEGRAM_ADMINS", user_id MessageChannel.Telegram,
client_config.config,
user_id,
), ),
text=callback_text, text=callback_text,
is_callback=True, is_callback=True,
@@ -316,7 +329,9 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
userid=user_id, userid=user_id,
username=user_name, username=user_name,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "TELEGRAM_ADMINS", user_id MessageChannel.Telegram,
client_config.config,
user_id,
), ),
text=cleaned_text, text=cleaned_text,
message_id=message_id, message_id=message_id,
+12 -2
View File
@@ -3,7 +3,11 @@ from urllib.parse import quote, unquote
from typing import Optional, Union, List, Tuple, Any, Dict from typing import Optional, Union, List, Tuple, Any, Dict
from app.core.context import Context, MediaInfo 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.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules import _ModuleBase, _MessageBase
from app.modules.vocechat.vocechat import VoceChat from app.modules.vocechat.vocechat import VoceChat
@@ -11,6 +15,12 @@ from app.schemas import MessageChannel, CommingMessage, Notification
from app.schemas.types import ModuleType 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]): class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
_IMAGE_SUFFIXES = ( _IMAGE_SUFFIXES = (
".png", ".png",
@@ -208,7 +218,7 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
return CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name, return CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name,
userid=userid, username=userid, userid=userid, username=userid,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "VOCECHAT_ADMINS", MessageChannel.VoceChat, client_config.config,
from_uid, actor_userid, from_uid, actor_userid,
), text=text or "", ), text=text or "",
images=images, audio_refs=audio_refs, files=files) images=images, audio_refs=audio_refs, files=files)
+27 -4
View File
@@ -7,7 +7,11 @@ from urllib.parse import quote
from app.core.context import Context, MediaInfo from app.core.context import Context, MediaInfo
from app.core.event import eventmanager 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.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules import _ModuleBase, _MessageBase
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
@@ -19,6 +23,17 @@ from app.utils.dom import DomUtils
from app.utils.structures import DictUtils 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]): class WechatModule(_ModuleBase, _MessageBase[WeChat]):
def init_module(self) -> None: def init_module(self) -> None:
@@ -88,7 +103,11 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
admins = cls._get_admins(config) admins = cls._get_admins(config)
if not admins: if not admins:
return False return False
return str(user_id or "").strip() not in admins return not matches_channel_admin(
MessageChannel.Wechat,
config,
user_id,
)
@classmethod @classmethod
def _create_client(cls, conf): def _create_client(cls, conf):
@@ -253,7 +272,9 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name, return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name,
userid=user_id, username=user_id, userid=user_id, username=user_id,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "WECHAT_ADMINS", user_id MessageChannel.Wechat,
client_config.config,
user_id,
), text=content or "", ), text=content or "",
images=images, audio_refs=audio_refs, files=files) images=images, audio_refs=audio_refs, files=files)
except Exception as err: except Exception as err:
@@ -325,7 +346,9 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
userid=sender, userid=sender,
username=sender, username=sender,
is_channel_admin=matches_channel_admin( is_channel_admin=matches_channel_admin(
client_config.config, "WECHAT_ADMINS", sender MessageChannel.Wechat,
client_config.config,
sender,
), ),
text=text or "", text=text or "",
images=images, images=images,
+12 -1
View File
@@ -15,8 +15,10 @@ from app.core.cache import FileCache
from app.core.config import settings from app.core.config import settings
from app.core.context import MediaInfo, Context from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.helper.agent import matches_channel_admin
from app.log import logger from app.log import logger
from app.schemas import CommingMessage from app.schemas import CommingMessage
from app.schemas.types import MessageChannel
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.string import StringUtils from app.utils.string import StringUtils
@@ -489,7 +491,16 @@ class WeChatBot:
self._remember_target(sender) 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) self.send_msg(title="只有管理员才有权限执行此命令", userid=sender)
return return
+20 -5
View File
@@ -3,7 +3,11 @@ from typing import Any, List, Optional, Tuple, Union
from app.core.cache import TTLCache from app.core.cache import TTLCache
from app.core.context import Context, MediaInfo 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.log import logger
from app.modules import _MessageBase, _ModuleBase from app.modules import _MessageBase, _ModuleBase
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
@@ -11,6 +15,14 @@ from app.schemas import CommingMessage, Notification
from app.schemas.types import MessageChannel, ModuleType 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]): class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
def __init__(self): def __init__(self):
"""初始化模块级去重缓存,拦截 iLink 偶发的重复回放消息。""" """初始化模块级去重缓存,拦截 iLink 偶发的重复回放消息。"""
@@ -182,7 +194,12 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
] ]
callback_data = text[9:].strip() if text.startswith("CALLBACK:") else "" callback_data = text[9:].strip() if text.startswith("CALLBACK:") else ""
is_admin_command = text.startswith("/") or callback_data.startswith("/") 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) client = self.get_instance(client_config.name)
if client: if client:
client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id) client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)
@@ -199,9 +216,7 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
source=client_config.name, source=client_config.name,
userid=user_id, userid=user_id,
username=username, username=username,
is_channel_admin=matches_channel_admin( is_channel_admin=is_channel_admin,
client_config.config, "WECHATCLAWBOT_ADMINS", user_id
),
text=text, text=text,
message_id=message_id, message_id=message_id,
chat_id=str(message.get("chat_id") or "") or None, chat_id=str(message.get("chat_id") or "") or None,
+1 -1
View File
@@ -168,7 +168,7 @@ class CommingMessage(BaseModel):
userid: Optional[Union[str, int]] = None userid: Optional[Union[str, int]] = None
# 用户名称 # 用户名称
username: Optional[Union[str, int]] = None username: Optional[Union[str, int]] = None
# 渠道适配器依据稳定用户 ID 与当前实例管理员名单生成的授权事实 # 渠道适配器依据稳定用户 ID、管理员名单及渠道主用户 ID 生成的授权事实
is_channel_admin: Optional[bool] = None is_channel_admin: Optional[bool] = None
# 消息渠道 # 消息渠道
channel: Optional[MessageChannel] = None channel: Optional[MessageChannel] = None
+239 -10
View File
@@ -4,7 +4,7 @@ from unittest.mock import Mock, patch
import pytest 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.discord import DiscordModule
from app.modules.feishu.feishu import Feishu from app.modules.feishu.feishu import Feishu
from app.modules.qqbot import QQBotModule 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.telegram import TelegramModule
from app.modules.vocechat import VoceChatModule from app.modules.vocechat import VoceChatModule
from app.modules.wechat import WechatModule from app.modules.wechat import WechatModule
from app.modules.wechat.wechatbot import WeChatBot
from app.modules.wechatclawbot import WechatClawBotModule from app.modules.wechatclawbot import WechatClawBotModule
from app.schemas.types import MessageChannel
def _parse_module_message(module, *, config: dict, body, client=None, form=None): 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( @pytest.mark.parametrize(
("config", "principal_ids", "expected"), ("config", "expected"),
[ [
({"ADMINS": " user-1, 42 "}, ("user-1",), True), ({"ADMINS": " user-1, 42 "}, {"user-1", "42"}),
({"ADMINS": "user-1,42"}, ("user-2", 7), False), ({"ADMINS": ""}, set()),
({"ADMINS": ""}, ("user-1",), False), ({}, set()),
({}, ("user-1",), False), (None, set()),
(None, ("user-1",), False),
], ],
) )
def test_matches_channel_admin_uses_nonempty_stable_principal_set( def test_resolve_config_principal_ids_uses_nonempty_stable_values(config, expected):
config, principal_ids, 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"]) @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 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(): def test_telegram_slash_does_not_accept_admin_display_username():
"""Telegram 斜杠命令不得把可修改的 username 当作管理员 ID。""" """Telegram 斜杠命令不得把可修改的 username 当作管理员 ID。"""
module = TelegramModule() 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 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( @pytest.mark.parametrize(
("user_id", "username", "expected"), ("user_id", "username", "expected"),
[("wxid_admin", "renamed-user", True), ("wxid_user", "admin-name", False)], [("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 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( @pytest.mark.parametrize(
("sender", "admins", "expected"), ("sender", "admins", "expected"),
[("wechat-admin", "wechat-admin", True), ("wechat-user", "display-admin", False)], [("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 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(): def test_qq_c2c_uses_user_openid_for_admin():
message = _parse_module_message( message = _parse_module_message(
QQBotModule(), QQBotModule(),
@@ -354,6 +568,21 @@ def test_qq_c2c_uses_user_openid_for_admin():
assert message.is_channel_admin is True 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( @pytest.mark.parametrize(
("admins", "expected"), ("admins", "expected"),
[("member-admin", True), ("group:group-admin", False), ("group-admin", False)], [("member-admin", True), ("group:group-admin", False), ("group-admin", False)],
@@ -15,9 +15,15 @@ from app.agent.tools.impl.write_file import WriteFileTool
from app.agent.tools.manager import MoviePilotToolsManager from app.agent.tools.manager import MoviePilotToolsManager
from app.agent import MoviePilotAgent from app.agent import MoviePilotAgent
from app.core.config import settings from app.core.config import settings
from app.modules.feishu import FeishuModule
from app.modules.telegram import TelegramModule
from app.schemas.types import MessageChannel from app.schemas.types import MessageChannel
# 渠道模块在导入时注册管理员解析器,权限回查测试需显式加载对应模块。
_REGISTERED_CHANNEL_MODULES = (FeishuModule, TelegramModule)
def test_non_admin_manager_exposes_resource_flow_helper_tools(): def test_non_admin_manager_exposes_resource_flow_helper_tools():
"""普通用户应能看到搜索、订阅、下载流程所需的辅助工具。""" """普通用户应能看到搜索、订阅、下载流程所需的辅助工具。"""
site_tool = QuerySitesTool(session_id="session-1", user_id="10001") site_tool = QuerySitesTool(session_id="session-1", user_id="10001")