fix(agent): bind channel admin identity (#6294)

This commit is contained in:
InfinityPacer
2026-08-13 14:53:59 +08:00
committed by GitHub
parent eeec40cce3
commit 3aed078bbb
19 changed files with 752 additions and 74 deletions

View File

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

View File

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

View File

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

View File

@@ -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 前端可识别的按钮行。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
# 来源(渠道名称)

View 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

View File

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

View File

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

View File

@@ -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 的最终消息应清空渠道及渠道用户定位后广播。"""

View File

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