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