fix(agent): grant channel owners admin access

This commit is contained in:
jxxghp
2026-08-13 23:46:56 +08:00
parent 73df2aa331
commit 87b1caf3ff
16 changed files with 499 additions and 120 deletions

View File

@@ -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)}")

View File

@@ -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]]:

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,
),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

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.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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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)],

View File

@@ -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")