mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 02:54:20 +08:00
fix(agent): grant channel owners admin access
This commit is contained in:
@@ -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)}")
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user