fix(agent): close secret confirmation delivery gaps (#6285)

This commit is contained in:
InfinityPacer
2026-08-13 08:20:14 +08:00
committed by GitHub
parent a2e70b443d
commit 591a1d420d
16 changed files with 672 additions and 81 deletions
+90 -46
View File
@@ -317,7 +317,6 @@ class _PendingSecretConfirmation:
user_id: str user_id: str
channel: str channel: str
source: str source: str
original_chat_id: str
class MoviePilotAgent: class MoviePilotAgent:
@@ -339,7 +338,7 @@ class MoviePilotAgent:
replay_mode: ReplyMode = ReplyMode.DISPATCH, replay_mode: ReplyMode = ReplyMode.DISPATCH,
allow_message_tools: bool = True, allow_message_tools: bool = True,
output_callback: Optional[Callable[[str], None]] = None, output_callback: Optional[Callable[[str], None]] = None,
protected_output_callback: Optional[Callable[[str], None]] = None, protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
): ):
self.session_id = session_id self.session_id = session_id
self.user_id = user_id self.user_id = user_id
@@ -771,7 +770,7 @@ class MoviePilotAgent:
def set_protected_output_callback( def set_protected_output_callback(
self, self,
protected_output_callback: Optional[Callable[[str], None]], protected_output_callback: Optional[Callable[[str], Optional[bool]]],
) -> None: ) -> None:
"""更新仅供当前请求接收的受保护文本输出回调。""" """更新仅供当前请求接收的受保护文本输出回调。"""
self.protected_output_callback = protected_output_callback self.protected_output_callback = protected_output_callback
@@ -790,7 +789,7 @@ class MoviePilotAgent:
"""判断当前渠道能否把密钥结果直接交付给原用户。""" """判断当前渠道能否把密钥结果直接交付给原用户。"""
if self.channel == MessageChannel.WebAgent.value: if self.channel == MessageChannel.WebAgent.value:
return callable(self.protected_output_callback) return callable(self.protected_output_callback)
return self.channel in { return bool(self.user_id and self.source) and self.channel in {
MessageChannel.Telegram.value, MessageChannel.Telegram.value,
MessageChannel.Feishu.value, MessageChannel.Feishu.value,
} }
@@ -831,15 +830,6 @@ class MoviePilotAgent:
self._pending_secret_confirmation = None self._pending_secret_confirmation = None
return permission_result return permission_result
self._pending_secret_confirmation = _PendingSecretConfirmation(
tool=tool,
arguments=validated_arguments,
created_at=datetime.now(),
user_id=str(self.user_id or ""),
channel=str(self.channel or ""),
source=str(self.source or ""),
original_chat_id=str(self.original_chat_id or ""),
)
target = validated_arguments.get("setting_key") or ( target = validated_arguments.get("setting_key") or (
validated_arguments.get("group") or "all" validated_arguments.get("group") or "all"
) )
@@ -849,34 +839,73 @@ class MoviePilotAgent:
"请在 5 分钟内回复“确认”继续,或回复“取消”放弃。" "请在 5 分钟内回复“确认”继续,或回复“取消”放弃。"
) )
if self.channel == MessageChannel.WebAgent.value: if self.channel == MessageChannel.WebAgent.value:
self._pending_secret_confirmation = _PendingSecretConfirmation(
tool=tool,
arguments=validated_arguments,
created_at=datetime.now(),
user_id=str(self.user_id or ""),
channel=str(self.channel or ""),
source=str(self.source or ""),
)
self._emit_output(confirmation_message) self._emit_output(confirmation_message)
else: else:
await self.send_agent_message(confirmation_message) delivered = await self._deliver_private_channel_message(
confirmation_message
)
if not delivered:
self._pending_secret_confirmation = None
return "无法向当前用户建立私聊,未执行敏感设置读取。"
self._pending_secret_confirmation = _PendingSecretConfirmation(
tool=tool,
arguments=validated_arguments,
created_at=datetime.now(),
user_id=str(self.user_id or ""),
channel=str(self.channel or ""),
source=str(self.source or ""),
)
self._tool_context["user_reply_sent"] = True self._tool_context["user_reply_sent"] = True
return confirmation_message return confirmation_message
async def _deliver_protected_output(self, content: str) -> None: async def _deliver_private_channel_message(self, content: str) -> bool:
"""按渠道用户身份私聊投递,禁止回退群聊或广播。"""
if self.channel not in {
MessageChannel.Telegram.value,
MessageChannel.Feishu.value,
}:
return False
try:
response = await run_in_threadpool(
AgentChain().send_direct_message,
Notification(
channel=self.channel,
source=self.source,
mtype=NotificationType.Agent,
userid=self.user_id,
username=self.username,
text=content,
private_delivery=True,
parse_mode="plain",
save_history=False,
),
)
except Exception as error:
logger.error(
f"Agent私聊投递失败: channel={self.channel}, "
f"error_type={type(error).__name__}"
)
return False
return bool(response and response.success)
async def _deliver_protected_output(self, content: str) -> bool:
"""绕过模型与会话历史,把敏感结果直接交付给当前用户。""" """绕过模型与会话历史,把敏感结果直接交付给当前用户。"""
if callable(self.protected_output_callback): if callable(self.protected_output_callback):
try: try:
self.protected_output_callback(content) delivered = self.protected_output_callback(content)
except Exception as e: except Exception as e:
logger.error(f"受保护输出回调失败: {e}") logger.error(f"受保护输出回调失败: {e}")
return return False
return delivered is not False
await AgentChain().async_post_message( return await self._deliver_private_channel_message(content)
Notification(
channel=self.channel,
source=self.source,
mtype=NotificationType.Agent,
userid=self.user_id,
username=self.username,
original_message_id=self.original_message_id,
original_chat_id=self.original_chat_id,
text=content,
save_history=False,
)
)
async def _handle_secret_confirmation_control( async def _handle_secret_confirmation_control(
self, self,
@@ -899,7 +928,6 @@ class MoviePilotAgent:
pending.user_id != str(self.user_id or "") pending.user_id != str(self.user_id or "")
or pending.channel != str(self.channel or "") or pending.channel != str(self.channel or "")
or pending.source != str(self.source or "") or pending.source != str(self.source or "")
or pending.original_chat_id != str(self.original_chat_id or "")
): ):
return None return None
if datetime.now() - pending.created_at > SECRET_CONFIRMATION_TTL: if datetime.now() - pending.created_at > SECRET_CONFIRMATION_TTL:
@@ -913,16 +941,37 @@ class MoviePilotAgent:
await self._deliver_protected_output(message_text) await self._deliver_protected_output(message_text)
return message_text return message_text
permission_result = await pending.tool._check_permission()
if permission_result:
await self._deliver_protected_output(permission_result)
return permission_result
if not self._can_confirm_secret_read(): if not self._can_confirm_secret_read():
return "当前入口不支持安全交付敏感设置,未执行读取。" return "当前入口不支持安全交付敏感设置,未执行读取。"
result = await pending.tool._run_confirmed(**pending.arguments) async def _execute_confirmed() -> str:
await self._deliver_protected_output(result) permission_result = await pending.tool._check_permission()
if permission_result:
return permission_result
return await pending.tool._run_confirmed(**pending.arguments)
policy = AgentPolicyMiddleware(
context=self._build_policy_context(),
tools=[pending.tool],
)
try:
_, result = await policy.execute_tool_call(
tool=pending.tool,
arguments=pending.arguments,
invocation_id=f"secret-confirmation-{uuid.uuid4().hex}",
handler=_execute_confirmed,
)
except Exception:
message_text = "敏感设置读取失败,请稍后重试。"
await self._deliver_protected_output(message_text)
return message_text
delivered = await self._deliver_protected_output(result)
if not delivered:
message_text = "敏感设置读取已完成,但结果投递失败,请重新发起。"
self._emit_output(message_text)
if self.should_dispatch_reply:
await self.send_agent_message(message_text)
return message_text
return "敏感设置确认已处理。" return "敏感设置确认已处理。"
def _build_policy_context(self) -> ToolPolicyContext: def _build_policy_context(self) -> ToolPolicyContext:
@@ -2166,7 +2215,7 @@ class _MessageTask:
reply_mode: ReplyMode = ReplyMode.DISPATCH reply_mode: ReplyMode = ReplyMode.DISPATCH
allow_message_tools: bool = True allow_message_tools: bool = True
output_callback: Optional[Callable[[str], None]] = None output_callback: Optional[Callable[[str], None]] = None
protected_output_callback: Optional[Callable[[str], None]] = None protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None
notification_callback: Optional[Callable[[Any], None]] = None notification_callback: Optional[Callable[[Any], None]] = None
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
completion_future: Optional[asyncio.Future] = None completion_future: Optional[asyncio.Future] = None
@@ -2227,7 +2276,6 @@ class AgentManager:
user_id: str, user_id: str,
channel: Optional[str] = None, channel: Optional[str] = None,
source: Optional[str] = None, source: Optional[str] = None,
original_chat_id: Optional[str] = None,
) -> bool: ) -> bool:
"""判断指定用户是否可继续当前会话的敏感设置确认。""" """判断指定用户是否可继续当前会话的敏感设置确认。"""
agent = self.active_agents.get(session_id) agent = self.active_agents.get(session_id)
@@ -2238,10 +2286,6 @@ class AgentManager:
and str(agent.user_id) == str(user_id) and str(agent.user_id) == str(user_id)
and (channel is None or pending.channel == str(channel)) and (channel is None or pending.channel == str(channel))
and (source is None or pending.source == str(source)) and (source is None or pending.source == str(source))
and (
original_chat_id is None
or pending.original_chat_id == str(original_chat_id)
)
) )
async def initialize(self): async def initialize(self):
@@ -2345,7 +2389,7 @@ class AgentManager:
reply_mode: ReplyMode = ReplyMode.DISPATCH, reply_mode: ReplyMode = ReplyMode.DISPATCH,
allow_message_tools: bool = True, allow_message_tools: bool = True,
output_callback: Optional[Callable[[str], None]] = None, output_callback: Optional[Callable[[str], None]] = None,
protected_output_callback: Optional[Callable[[str], None]] = None, protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
notification_callback: Optional[Callable[[Any], None]] = None, notification_callback: Optional[Callable[[Any], None]] = None,
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None, agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
wait_for_completion: bool = False, wait_for_completion: bool = False,
+35 -5
View File
@@ -17,6 +17,9 @@ from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
class AgentPolicyMiddleware(AgentMiddleware): class AgentPolicyMiddleware(AgentMiddleware):
"""观测进入本地 ToolNode 的 client-side 工具调用和结果。 """观测进入本地 ToolNode 的 client-side 工具调用和结果。
@@ -103,16 +106,43 @@ class AgentPolicyMiddleware(AgentMiddleware):
arguments = tool_call.get("args") or {} arguments = tool_call.get("args") or {}
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
arguments = {} arguments = {}
_, result = await self.execute_tool_call(
tool=request.tool,
arguments=arguments,
invocation_id=tool_call.get("id"),
handler=lambda: handler(request),
enforce_decision=False,
)
# 普通 ToolNode 在严格策略接管前保持 shadow 观测语义。
# 已确认的受保护调用会使用默认的强制决策语义。
return result
async def execute_tool_call(
self,
*,
tool: Any,
arguments: dict[str, Any],
handler: Callable[[], Awaitable[Any]],
invocation_id: str | None = None,
enforce_decision: bool = True,
) -> tuple[bool, Any]:
"""执行一次本地工具调用,并复用 ToolNode 的策略生命周期。"""
observation = call_policy_hook( observation = call_policy_hook(
"start", "start",
self.orchestrator.start, self.orchestrator.start,
context=self.context, context=self.context,
tool=request.tool, tool=tool,
arguments=arguments, arguments=arguments,
invocation_id=tool_call.get("id"), invocation_id=invocation_id,
) )
if (
enforce_decision
and observation is not None
and observation.decision.allowed is False
):
return False, POLICY_DENIED_MESSAGE
try: try:
result = await handler(request) result = await handler()
except Exception as error: except Exception as error:
if observation is not None: if observation is not None:
call_policy_hook( call_policy_hook(
@@ -129,7 +159,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
observation, observation,
result, result,
) )
return result return True, result
__all__ = ["AgentPolicyMiddleware"] __all__ = ["AgentPolicyMiddleware", "POLICY_DENIED_MESSAGE"]
+1
View File
@@ -21,6 +21,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
<confirmation_policy> <confirmation_policy>
- Do not stop for approval on read-only operations. - Do not stop for approval on read-only operations.
- Raw secret reads are protected operations rather than ordinary read-only queries. When a user explicitly asks for a raw credential or another unredacted sensitive setting, call `query_system_settings` with `show_secrets=true`; do not refuse the request solely because the value is sensitive. The host verifies administrator authority, obtains any required confirmation, and delivers the result through a protected channel. Never expose or repeat the secret in an ordinary assistant response, tool narration, or follow-up model context.
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`. - If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services. - Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools. - When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.
@@ -58,7 +58,10 @@ class QuerySystemSettingsInput(BaseModel):
False, False,
description=( description=(
"Whether to return raw secret values such as API keys, tokens, cookies, and passwords. " "Whether to return raw secret values such as API keys, tokens, cookies, and passwords. "
"Defaults to false; secret-like fields are redacted in returned values and previews." "Defaults to false; secret-like fields are redacted in returned values and previews. "
"Set this to true when the user explicitly asks for an unredacted secret; the host verifies "
"administrator authority, requests confirmation, and delivers the result outside the ordinary "
"model response. Do not refuse the tool call solely because the requested value is sensitive."
), ),
) )
+7 -7
View File
@@ -84,10 +84,10 @@ class _WebAgentEventPublisher:
"""返回本轮发布器观测到的最大积压深度。""" """返回本轮发布器观测到的最大积压深度。"""
return self._max_depth return self._max_depth
def publish(self, event: dict) -> None: def publish(self, event: dict) -> bool:
"""发布事件;相邻文本会按时间或长度边界合并""" """发布事件;返回关闭状态以便受保护投递能准确报告失败"""
if self._disposed: if self._disposed:
return return False
if event.get("type") == "delta": if event.get("type") == "delta":
self._pending_delta += str(event.get("content") or "") self._pending_delta += str(event.get("content") or "")
if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS: if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS:
@@ -98,10 +98,11 @@ class _WebAgentEventPublisher:
WEB_AGENT_STREAM_COALESCE_SECONDS, WEB_AGENT_STREAM_COALESCE_SECONDS,
self._flush_delta, self._flush_delta,
) )
return return True
self._flush_delta() self._flush_delta()
self._append_event(event) self._append_event(event)
return True
async def get(self) -> dict: async def get(self) -> dict:
"""等待并返回下一条已排序事件。""" """等待并返回下一条已排序事件。"""
@@ -1955,7 +1956,6 @@ async def web_agent_stream(
str(current_user.id), str(current_user.id),
channel=MessageChannel.WebAgent.value, channel=MessageChannel.WebAgent.value,
source=WEB_AGENT_SOURCE, source=WEB_AGENT_SOURCE,
original_chat_id=str(payload.original_chat_id or ""),
) )
) )
protected_transport_supported = ( protected_transport_supported = (
@@ -2177,9 +2177,9 @@ async def web_agent_stream(
_apply_web_agent_display_event(item, assistant_display_message) _apply_web_agent_display_event(item, assistant_display_message)
event_publisher.publish(item) event_publisher.publish(item)
def protected_output_callback(content: str) -> None: def protected_output_callback(content: str) -> bool:
"""将敏感文本封装为不进入普通展示快照的命名 SSE 事件。""" """将敏感文本封装为不进入普通展示快照的命名 SSE 事件。"""
event_publisher.publish( return event_publisher.publish(
{ {
"type": "interaction-protected", "type": "interaction-protected",
"content": content, "content": content,
-1
View File
@@ -323,7 +323,6 @@ class MessageChain(ChainBase):
str(userid), str(userid),
channel=channel.value, channel=channel.value,
source=source, source=source,
original_chat_id=original_chat_id,
): ):
return False return False
return self._handle_ai_message( return self._handle_ai_message(
+18
View File
@@ -59,6 +59,10 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
chat_id = None chat_id = None
receive_id_type = "open_id" if userid else None receive_id_type = "open_id" if userid else None
# 私聊投递只能按用户身份寻址,原会话 ID 可能属于群聊。
if message.private_delivery and userid:
return userid, None, None
# 回复类消息携带原会话 ID 时,必须发回原会话, # 回复类消息携带原会话 ID 时,必须发回原会话,
# 否则群聊 @ 机器人的回复会错误地发送到机器人与用户的私聊窗口。 # 否则群聊 @ 机器人的回复会错误地发送到机器人与用户的私聊窗口。
original_chat_id = str(message.original_chat_id or "").strip() or None original_chat_id = str(message.original_chat_id or "").strip() or None
@@ -246,6 +250,20 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
receive_id_type=receive_id_type, receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None, original_message_id=str(message.original_message_id) if message.original_message_id else None,
) )
elif str(message.parse_mode or "").strip().lower() == "plain":
# 受保护结果必须绕过 Markdown 卡片,避免密钥字符被解释或改写。
plain_text = "\n".join(
part
for part in (message.title, message.text, message.link)
if part
)
result = client.send_text(
text=plain_text,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
else: else:
result = client.send_notification( result = client.send_notification(
message=message, message=message,
+1
View File
@@ -761,6 +761,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
original_chat_id=original_chat_id, original_chat_id=original_chat_id,
disable_web_page_preview=message.disable_web_page_preview, disable_web_page_preview=message.disable_web_page_preview,
parse_mode=message.parse_mode, parse_mode=message.parse_mode,
private_delivery=message.private_delivery,
) )
if result and result.get("success"): if result and result.get("success"):
return MessageResponse( return MessageResponse(
+42 -6
View File
@@ -47,10 +47,13 @@ from app.utils.string import StringUtils # noqa: E402
TELEGRAM_PARSE_MODE_MARKDOWN = "MarkdownV2" TELEGRAM_PARSE_MODE_MARKDOWN = "MarkdownV2"
TELEGRAM_PARSE_MODE_HTML = "HTML" TELEGRAM_PARSE_MODE_HTML = "HTML"
TELEGRAM_PARSE_MODE_PLAIN = ""
TELEGRAM_PARSE_MODE_ALIASES = { TELEGRAM_PARSE_MODE_ALIASES = {
"markdownv2": TELEGRAM_PARSE_MODE_MARKDOWN, "markdownv2": TELEGRAM_PARSE_MODE_MARKDOWN,
"mdv2": TELEGRAM_PARSE_MODE_MARKDOWN, "mdv2": TELEGRAM_PARSE_MODE_MARKDOWN,
"html": TELEGRAM_PARSE_MODE_HTML, "html": TELEGRAM_PARSE_MODE_HTML,
"plain": TELEGRAM_PARSE_MODE_PLAIN,
"text": TELEGRAM_PARSE_MODE_PLAIN,
} }
@@ -295,10 +298,13 @@ class Telegram:
@staticmethod @staticmethod
def _normalize_parse_mode(parse_mode: Optional[str] = None) -> str: def _normalize_parse_mode(parse_mode: Optional[str] = None) -> str:
"""规范化 Telegram 消息格式类型。""" """规范化 Telegram 消息格式类型。"""
if not parse_mode: if parse_mode is None:
return TELEGRAM_PARSE_MODE_MARKDOWN return TELEGRAM_PARSE_MODE_MARKDOWN
normalized = str(parse_mode).strip()
if not normalized:
return TELEGRAM_PARSE_MODE_PLAIN
return TELEGRAM_PARSE_MODE_ALIASES.get( return TELEGRAM_PARSE_MODE_ALIASES.get(
str(parse_mode).strip().lower(), TELEGRAM_PARSE_MODE_MARKDOWN normalized.lower(), TELEGRAM_PARSE_MODE_MARKDOWN
) )
@staticmethod @staticmethod
@@ -306,11 +312,18 @@ class Telegram:
"""判断本次发送是否使用 Telegram HTML 格式。""" """判断本次发送是否使用 Telegram HTML 格式。"""
return Telegram._normalize_parse_mode(parse_mode) == TELEGRAM_PARSE_MODE_HTML return Telegram._normalize_parse_mode(parse_mode) == TELEGRAM_PARSE_MODE_HTML
@staticmethod
def _is_plain_parse_mode(parse_mode: Optional[str] = None) -> bool:
"""判断本次发送是否禁用 Telegram 文本格式解析。"""
return Telegram._normalize_parse_mode(parse_mode) == TELEGRAM_PARSE_MODE_PLAIN
@staticmethod @staticmethod
def _format_title(title: Optional[str], parse_mode: Optional[str] = None) -> Optional[str]: def _format_title(title: Optional[str], parse_mode: Optional[str] = None) -> Optional[str]:
"""按 parse_mode 生成 Telegram 标题文本。""" """按 parse_mode 生成 Telegram 标题文本。"""
if not title: if not title:
return None return None
if Telegram._is_plain_parse_mode(parse_mode):
return title.removesuffix("\n")
if Telegram._is_html_parse_mode(parse_mode): if Telegram._is_html_parse_mode(parse_mode):
return f"<b>{html_utils.escape(title).removesuffix(chr(10))}</b>" return f"<b>{html_utils.escape(title).removesuffix(chr(10))}</b>"
return f"**{standardize(title).removesuffix(chr(10))}**" return f"**{standardize(title).removesuffix(chr(10))}**"
@@ -318,6 +331,8 @@ class Telegram:
@staticmethod @staticmethod
def _format_link(label: str, link: str, parse_mode: Optional[str] = None) -> str: def _format_link(label: str, link: str, parse_mode: Optional[str] = None) -> str:
"""按 parse_mode 生成 Telegram 链接文本。""" """按 parse_mode 生成 Telegram 链接文本。"""
if Telegram._is_plain_parse_mode(parse_mode):
return f"{label}: {link}"
if Telegram._is_html_parse_mode(parse_mode): if Telegram._is_html_parse_mode(parse_mode):
return ( return (
f'<a href="{html_utils.escape(link, quote=True)}">' f'<a href="{html_utils.escape(link, quote=True)}">'
@@ -328,6 +343,8 @@ class Telegram:
@staticmethod @staticmethod
def _format_italic(text: str, parse_mode: Optional[str] = None) -> str: def _format_italic(text: str, parse_mode: Optional[str] = None) -> str:
"""按 parse_mode 生成 Telegram 斜体文本。""" """按 parse_mode 生成 Telegram 斜体文本。"""
if Telegram._is_plain_parse_mode(parse_mode):
return text
if Telegram._is_html_parse_mode(parse_mode): if Telegram._is_html_parse_mode(parse_mode):
return f"<i>{html_utils.escape(text)}</i>" return f"<i>{html_utils.escape(text)}</i>"
return f"_{text}_" return f"_{text}_"
@@ -342,7 +359,10 @@ class Telegram:
"""按 parse_mode 生成 Telegram 可发送文本。""" """按 parse_mode 生成 Telegram 可发送文本。"""
if not text: if not text:
return None return None
if Telegram._is_html_parse_mode(parse_mode): if (
Telegram._is_plain_parse_mode(parse_mode)
or Telegram._is_html_parse_mode(parse_mode)
):
return text return text
return standardize(text) return standardize(text)
@@ -595,6 +615,7 @@ class Telegram:
disable_web_page_preview: Optional[bool] = None, disable_web_page_preview: Optional[bool] = None,
stop_typing: bool = False, stop_typing: bool = False,
parse_mode: Optional[str] = None, parse_mode: Optional[str] = None,
private_delivery: bool = False,
) -> Optional[dict]: ) -> Optional[dict]:
""" """
发送Telegram消息 发送Telegram消息
@@ -610,6 +631,7 @@ class Telegram:
:param disable_web_page_preview: 是否禁用链接预览 :param disable_web_page_preview: 是否禁用链接预览
:param stop_typing: 发送完成后是否立即停止 typing :param stop_typing: 发送完成后是否立即停止 typing
:param parse_mode: Telegram 消息格式类型默认 MarkdownV2可传 HTML :param parse_mode: Telegram 消息格式类型默认 MarkdownV2可传 HTML
:param private_delivery: 是否绕过最近会话映射直接以用户 ID 作为私聊目标
:return: 包含 message_id, chat_id, success 的字典 :return: 包含 message_id, chat_id, success 的字典
""" """
if not self._telegram_token or not self._telegram_chat_id: if not self._telegram_token or not self._telegram_chat_id:
@@ -617,7 +639,11 @@ class Telegram:
parse_mode = self._normalize_parse_mode(parse_mode) parse_mode = self._normalize_parse_mode(parse_mode)
# Determine target chat_id with improved logic using user mapping # Determine target chat_id with improved logic using user mapping
chat_id = self._determine_target_chat_id(userid, original_chat_id) chat_id = self._determine_target_chat_id(
userid,
original_chat_id,
private_delivery=private_delivery,
)
if not title and not text: if not title and not text:
logger.warn("标题和内容不能同时为空") logger.warn("标题和内容不能同时为空")
self._stop_typing_if_needed(chat_id, stop_typing) self._stop_typing_if_needed(chat_id, stop_typing)
@@ -836,7 +862,10 @@ class Telegram:
return {"success": False} return {"success": False}
def _determine_target_chat_id( def _determine_target_chat_id(
self, userid: Optional[str] = None, original_chat_id: Optional[str] = None self,
userid: Optional[str] = None,
original_chat_id: Optional[str] = None,
private_delivery: bool = False,
) -> str: ) -> str:
""" """
确定目标聊天ID使用用户映射确保回复到正确的聊天 确定目标聊天ID使用用户映射确保回复到正确的聊天
@@ -844,6 +873,10 @@ class Telegram:
:param original_chat_id: 原消息的聊天ID :param original_chat_id: 原消息的聊天ID
:return: 目标聊天ID :return: 目标聊天ID
""" """
# 私聊投递以渠道用户 ID 为目标,最近会话映射可能指向群聊,不能参与解析。
if private_delivery and userid:
return str(userid)
# 1. 优先使用原消息的聊天ID (编辑消息场景) # 1. 优先使用原消息的聊天ID (编辑消息场景)
if original_chat_id: if original_chat_id:
return original_chat_id return original_chat_id
@@ -1354,7 +1387,10 @@ class Telegram:
ret = self.__send_short_message(image, caption, ret = self.__send_short_message(image, caption,
disable_web_page_preview=disable_web_page_preview, disable_web_page_preview=disable_web_page_preview,
**kwargs) **kwargs)
elif self._is_html_parse_mode(parse_mode): elif (
self._is_plain_parse_mode(parse_mode)
or self._is_html_parse_mode(parse_mode)
):
ret = self.__send_long_plain_message( ret = self.__send_long_plain_message(
image, image,
caption, caption,
+3 -1
View File
@@ -262,9 +262,11 @@ class Notification(BaseModel):
original_message_id: Optional[Union[str, int]] = None original_message_id: Optional[Union[str, int]] = None
# 原消息的聊天ID,用于编辑消息 # 原消息的聊天ID,用于编辑消息
original_chat_id: Optional[str] = None original_chat_id: Optional[str] = None
# 是否必须按用户身份投递到私聊,禁止回退原会话或最近会话映射
private_delivery: bool = False
# 是否禁用链接预览(仅Telegram支持) # 是否禁用链接预览(仅Telegram支持)
disable_web_page_preview: Optional[bool] = None disable_web_page_preview: Optional[bool] = None
# Telegram 消息格式类型,默认 MarkdownV2,可传 HTML # 消息文本格式;Telegram 支持 MarkdownV2、HTML、plain,飞书直发支持 plain
parse_mode: Optional[str] = None parse_mode: Optional[str] = None
# 是否写入消息历史 # 是否写入消息历史
save_history: bool = True save_history: bool = True
+17
View File
@@ -1,4 +1,5 @@
from app.agent.prompt import PromptManager from app.agent.prompt import PromptManager
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsInput
from app.core.config import settings from app.core.config import settings
@@ -64,3 +65,19 @@ def test_moviepilot_info_lists_command_names_without_paths(monkeypatch) -> None:
assert "/opt/homebrew/bin/rg" not in moviepilot_info assert "/opt/homebrew/bin/rg" not in moviepilot_info
assert "/usr/local/bin/ffmpeg" not in moviepilot_info assert "/usr/local/bin/ffmpeg" not in moviepilot_info
assert "rg --files" in moviepilot_info assert "rg --files" in moviepilot_info
def test_agent_prompt_delegates_explicit_secret_reads_to_host_confirmation() -> None:
"""管理员明确索取密钥时,模型应发起工具调用并把授权交给宿主。"""
prompt = PromptManager().get_agent_prompt(channel="webagent")
assert "query_system_settings" in prompt
assert "show_secrets=true" in prompt
assert "do not refuse" in prompt
assert "host verifies administrator authority" in prompt
assert "Never expose or repeat the secret" in prompt
field_description = QuerySystemSettingsInput.model_fields["show_secrets"].description or ""
assert "user explicitly asks" in field_description
assert "host verifies administrator authority" in field_description
assert "Do not refuse the tool call" in field_description
+317 -11
View File
@@ -2,7 +2,8 @@
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
@@ -288,7 +289,6 @@ def test_expired_confirmation_reaches_agent_expiry_receipt() -> None:
"1", "1",
channel=MessageChannel.WebAgent.value, channel=MessageChannel.WebAgent.value,
source="web-agent", source="web-agent",
original_chat_id="",
) )
return await agent.process("确认") return await agent.process("确认")
finally: finally:
@@ -324,7 +324,7 @@ def test_background_agent_refuses_secret_read_without_pending() -> None:
def test_message_channel_receives_confirmation_prompt_once() -> None: def test_message_channel_receives_confirmation_prompt_once() -> None:
"""TG/飞书应由宿主直接发送确认提示,不依赖图状态转成渠道输出""" """TG/飞书应先向用户私聊发送提示,再登记待确认操作"""
agent = MoviePilotAgent( agent = MoviePilotAgent(
session_id="session-secret", session_id="session-secret",
user_id="1", user_id="1",
@@ -347,17 +347,79 @@ def test_message_channel_receives_confirmation_prompt_once() -> None:
new=AsyncMock(return_value=True), new=AsyncMock(return_value=True),
), patch.object( ), patch.object(
agent, agent,
"send_agent_message", "_deliver_private_channel_message",
new=AsyncMock(), new=AsyncMock(return_value=True),
) as send_message: ) as send_message:
prompt = asyncio.run(scenario()) prompt = asyncio.run(scenario())
send_message.assert_awaited_once_with(prompt) send_message.assert_awaited_once_with(prompt)
assert agent._tool_context["user_reply_sent"] is True assert agent._tool_context["user_reply_sent"] is True
assert agent.has_pending_secret_confirmation() is True
def test_pending_secret_read_keeps_original_owner_and_action() -> None: def test_message_channel_does_not_register_pending_when_private_delivery_fails() -> None:
"""新请求不得覆盖 pending,错误交付目标也不得消费它""" """无法建立私聊时不得等待确认,更不能回退群聊投递结果"""
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.Feishu.value,
source="feishu-main",
username="admin",
original_chat_id="group-1",
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(
agent,
"_deliver_private_channel_message",
new=AsyncMock(return_value=False),
) as deliver,
):
result = asyncio.run(
agent._register_secret_confirmation(
tool,
{"setting_key": "TMDB_API_KEY", "show_secrets": True},
)
)
assert result == "无法向当前用户建立私聊,未执行敏感设置读取。"
deliver.assert_awaited_once()
assert agent.has_pending_secret_confirmation() is False
def test_private_delivery_requests_literal_plain_text() -> None:
"""敏感提示与结果均须请求渠道按纯文本私聊投递。"""
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.Telegram.value,
source="telegram-main",
username="admin",
original_chat_id="group-1",
)
response = SimpleNamespace(success=True)
with patch(
"app.agent.AgentChain.send_direct_message",
return_value=response,
) as send_direct:
delivered = asyncio.run(
agent._deliver_private_channel_message(
"G2A1_PROTECTED_MARKER_20260812\n**literal markdown**\n<img src=x>"
)
)
assert delivered is True
notification = send_direct.call_args.args[0]
assert notification.private_delivery is True
assert notification.parse_mode == "plain"
assert notification.original_chat_id is None
def test_pending_secret_read_keeps_actor_and_action_across_chat_targets() -> None:
"""新请求不得覆盖 pending,同一用户可从私聊消费群聊发起的确认。"""
agent = MoviePilotAgent( agent = MoviePilotAgent(
session_id="session-secret", session_id="session-secret",
user_id="1", user_id="1",
@@ -367,6 +429,8 @@ def test_pending_secret_read_keeps_original_owner_and_action() -> None:
original_chat_id="chat-1", original_chat_id="chat-1",
) )
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1") tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
tool.set_agent_context(agent._tool_context)
delivered = []
async def scenario() -> tuple[str, str, str]: async def scenario() -> tuple[str, str, str]:
first = await agent._register_secret_confirmation( first = await agent._register_secret_confirmation(
@@ -384,12 +448,254 @@ def test_pending_secret_read_keeps_original_owner_and_action() -> None:
with ( with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)), patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(agent, "_execute_agent", new=AsyncMock(return_value="普通回复")), patch.object(agent, "_execute_agent", new=AsyncMock(return_value="普通回复")),
patch.object(QuerySystemSettingsTool, "_load_setting_value") as load_value, patch.object(
agent,
"_deliver_private_channel_message",
new=AsyncMock(side_effect=lambda content: delivered.append(content) or True),
),
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value="secret-marker",
) as load_value,
): ):
first, second, result = asyncio.run(scenario()) first, second, result = asyncio.run(scenario())
assert "TMDB_API_KEY" in first assert "TMDB_API_KEY" in first
assert "已有待确认" in second assert "已有待确认" in second
assert result == "普通回复" assert result == "敏感设置确认已处理。"
load_value.assert_not_called() load_value.assert_called_once()
assert agent.has_pending_secret_confirmation() is True assert "secret-marker" in delivered[-1]
assert agent.has_pending_secret_confirmation() is False
def test_confirm_reports_result_delivery_failure_without_secret() -> None:
"""工具已读取但受保护结果未送达时,只能通过普通渠道报告非敏感失败。"""
secret_marker = "confirmed-secret-marker"
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.Telegram.value,
source="telegram-main",
username="admin",
original_chat_id="group-1",
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
tool.set_agent_context(agent._tool_context)
async def scenario() -> str:
await agent._register_secret_confirmation(
tool,
{"setting_key": "TMDB_API_KEY", "show_secrets": True},
)
return await agent.process("确认")
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(
agent,
"_deliver_private_channel_message",
new=AsyncMock(side_effect=[True, False]),
) as deliver,
patch.object(agent, "send_agent_message", new=AsyncMock()) as send_notice,
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value=secret_marker,
),
):
result = asyncio.run(scenario())
assert result == "敏感设置读取已完成,但结果投递失败,请重新发起。"
assert deliver.await_count == 2
assert secret_marker in deliver.await_args_list[-1].args[0]
send_notice.assert_awaited_once_with(result)
assert secret_marker not in send_notice.await_args.args[0]
def test_web_protected_callback_failure_returns_ordinary_safe_notice() -> None:
"""Web 受保护回调失败时,普通流只能收到不含敏感结果的提示。"""
secret_marker = "confirmed-secret-marker"
ordinary_output = []
def broken_protected_callback(_content: str) -> None:
raise RuntimeError("delivery unavailable")
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
output_callback=ordinary_output.append,
protected_output_callback=broken_protected_callback,
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
tool.set_agent_context(agent._tool_context)
async def scenario() -> str:
await agent._register_secret_confirmation(
tool,
{"setting_key": "TMDB_API_KEY", "show_secrets": True},
)
ordinary_output.clear()
return await agent.process("确认")
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value=secret_marker,
),
):
result = asyncio.run(scenario())
assert result == "敏感设置读取已完成,但结果投递失败,请重新发起。"
assert ordinary_output == [result]
assert secret_marker not in ordinary_output[0]
def test_confirm_reuses_policy_lifecycle() -> None:
"""确认后的冻结调用必须生成与 ToolNode 相同的 start/finish 生命周期。"""
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
protected_output_callback=lambda _content: None,
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
tool.set_agent_context(agent._tool_context)
async def scenario() -> str:
await agent._register_secret_confirmation(
tool,
{"setting_key": "TMDB_API_KEY", "show_secrets": True},
)
return await agent.process("确认")
from app.agent.policy import DEFAULT_TOOL_POLICY_ORCHESTRATOR
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(QuerySystemSettingsTool, "_load_setting_value", return_value="secret"),
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"start",
wraps=DEFAULT_TOOL_POLICY_ORCHESTRATOR.start,
) as start,
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"finish",
wraps=DEFAULT_TOOL_POLICY_ORCHESTRATOR.finish,
) as finish,
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"fail",
wraps=DEFAULT_TOOL_POLICY_ORCHESTRATOR.fail,
) as fail,
):
result = asyncio.run(scenario())
assert result == "敏感设置确认已处理。"
start.assert_called_once()
finish.assert_called_once()
fail.assert_not_called()
def test_confirm_respects_policy_denial_without_running_tool() -> None:
"""确认不能覆盖宿主策略的拒绝决定。"""
protected_output = []
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
protected_output_callback=protected_output.append,
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
tool.set_agent_context(agent._tool_context)
denied = SimpleNamespace(decision=SimpleNamespace(allowed=False))
async def scenario() -> str:
await agent._register_secret_confirmation(
tool,
{"setting_key": "TMDB_API_KEY", "show_secrets": True},
)
return await agent.process("确认")
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch("app.agent.middleware.policy.DEFAULT_TOOL_POLICY_ORCHESTRATOR.start", return_value=denied),
patch.object(QuerySystemSettingsTool, "_run_confirmed", new=AsyncMock()) as run_tool,
):
result = asyncio.run(scenario())
assert result == "敏感设置确认已处理。"
assert protected_output[-1] == "当前宿主策略不允许执行该工具。"
run_tool.assert_not_awaited()
def test_confirm_records_policy_failure_and_returns_protected_error() -> None:
"""确认执行异常必须闭合 fail 生命周期,且不把异常交给普通对话。"""
protected_output = []
agent = MoviePilotAgent(
session_id="session-secret",
user_id="1",
channel=MessageChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
protected_output_callback=protected_output.append,
)
tool = QuerySystemSettingsTool(session_id="session-secret", user_id="1")
tool.set_agent_context(agent._tool_context)
orchestrator = MagicMock()
orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=True)
)
async def scenario() -> str:
await agent._register_secret_confirmation(
tool,
{"setting_key": "TMDB_API_KEY", "show_secrets": True},
)
return await agent.process("确认")
from app.agent.policy import DEFAULT_TOOL_POLICY_ORCHESTRATOR
with (
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"start",
return_value=orchestrator.start.return_value,
),
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"fail",
side_effect=orchestrator.fail,
) as fail,
patch.object(
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
"finish",
side_effect=orchestrator.finish,
) as finish,
patch.object(
QuerySystemSettingsTool,
"_run_confirmed",
new=AsyncMock(side_effect=RuntimeError("secret-bearing-error")),
),
):
result = asyncio.run(scenario())
assert result == "敏感设置读取失败,请稍后重试。"
assert protected_output[-1] == result
fail.assert_called_once()
finish.assert_not_called()
+32 -2
View File
@@ -373,7 +373,9 @@ def test_middleware_observation_failure_does_not_replace_success(
) -> None: ) -> None:
"""shadow start/finish 故障不能阻止 handler 或替换成功结果。""" """shadow start/finish 故障不能阻止 handler 或替换成功结果。"""
orchestrator = MagicMock() orchestrator = MagicMock()
orchestrator.start.return_value = object() orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=True)
)
getattr(orchestrator, failed_phase).side_effect = RuntimeError( getattr(orchestrator, failed_phase).side_effect = RuntimeError(
f"policy-{failed_phase}-failure" f"policy-{failed_phase}-failure"
) )
@@ -402,7 +404,9 @@ def test_middleware_observation_failure_does_not_replace_success(
def test_middleware_fail_observation_does_not_mask_tool_error() -> None: def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
"""shadow fail hook 故障后仍必须抛出原始工具异常。""" """shadow fail hook 故障后仍必须抛出原始工具异常。"""
orchestrator = MagicMock() orchestrator = MagicMock()
orchestrator.start.return_value = object() orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=True)
)
orchestrator.fail.side_effect = RuntimeError("policy-fail-hook-failure") orchestrator.fail.side_effect = RuntimeError("policy-fail-hook-failure")
middleware = AgentPolicyMiddleware( middleware = AgentPolicyMiddleware(
context=_interactive_context(), context=_interactive_context(),
@@ -423,6 +427,32 @@ def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
assert error_info.value is tool_error assert error_info.value is tool_error
def test_middleware_keeps_shadow_observation_until_strict_runtime_takeover() -> None:
"""普通 ToolNode 在严格策略接管前不得因观测决策改变既有行为。"""
orchestrator = MagicMock()
orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=False)
)
middleware = AgentPolicyMiddleware(
context=_interactive_context(),
orchestrator=orchestrator,
)
request = SimpleNamespace(
tool=_EchoTool(session_id="session-1", user_id="user-1"),
tool_call={"id": "call-1", "name": "policy_echo", "args": {"query": "same"}},
)
handler = AsyncMock(
return_value=ToolMessage(content="same", tool_call_id="call-1")
)
result = asyncio.run(middleware.awrap_tool_call(request, handler))
assert result.content == "same"
handler.assert_awaited_once_with(request)
orchestrator.finish.assert_called_once()
orchestrator.fail.assert_not_called()
def test_policy_hook_failure_logs_only_stable_type_information() -> None: def test_policy_hook_failure_logs_only_stable_type_information() -> None:
"""fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。""" """fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。"""
mock_logger = MagicMock() mock_logger = MagicMock()
+53
View File
@@ -1155,6 +1155,45 @@ class TestFeishu(unittest.TestCase):
self.assertEqual(response.message_id, "om_789") self.assertEqual(response.message_id, "om_789")
self.assertEqual(response.chat_id, "oc_789") self.assertEqual(response.chat_id, "oc_789")
def test_module_plain_direct_message_uses_literal_text_transport(self):
"""纯文本直发不得进入会解释密钥字符的 Markdown 卡片路径。"""
module = FeishuModule()
module._channel = MessageChannel.Feishu
conf = SimpleNamespace(name="feishu-main")
client = MagicMock()
client.send_text.return_value = {
"success": True,
"message_id": "om_plain",
"chat_id": "oc_plain",
}
literal_text = "G2A1_PROTECTED_MARKER_20260812\n**literal markdown**\n<img src=x>"
with (
patch.object(module, "get_configs", return_value={"feishu-main": conf}),
patch.object(module, "check_message", return_value=True),
patch.object(module, "get_instance", return_value=client),
):
response = module.send_direct_message(
Notification(
channel=MessageChannel.Feishu,
source="feishu-main",
userid="ou_target",
text=literal_text,
private_delivery=True,
parse_mode="plain",
)
)
client.send_text.assert_called_once_with(
text=literal_text,
userid="ou_target",
chat_id=None,
receive_id_type=None,
original_message_id=None,
)
client.send_notification.assert_not_called()
self.assertTrue(response.success)
def test_run_ws_client_binds_thread_local_event_loop(self): def test_run_ws_client_binds_thread_local_event_loop(self):
client = self._build_client() client = self._build_client()
original_loop = object() original_loop = object()
@@ -1515,6 +1554,20 @@ class TestFeishu(unittest.TestCase):
self.assertIsNone(userid) self.assertIsNone(userid)
self.assertEqual(chat_id, "oc_config") self.assertEqual(chat_id, "oc_config")
def test_module_private_delivery_ignores_original_group_chat(self):
"""私聊投递只保留用户身份,并让客户端按已记录 ID 类型发送。"""
userid, chat_id, receive_id_type = FeishuModule._resolve_message_target(
Notification(
userid="user_target",
original_chat_id="oc_group",
private_delivery=True,
)
)
self.assertEqual(userid, "user_target")
self.assertIsNone(chat_id)
self.assertIsNone(receive_id_type)
def test_module_post_message_replies_to_original_chat_for_group_message(self): def test_module_post_message_replies_to_original_chat_for_group_message(self):
"""携带原会话上下文的回复应定向到原会话(群聊)。""" """携带原会话上下文的回复应定向到原会话(群聊)。"""
module = FeishuModule() module = FeishuModule()
+33
View File
@@ -578,6 +578,39 @@ def test_telegram_module_plain_direct_message_keeps_userid_target():
assert response.message_id == 456 assert response.message_id == 456
def test_telegram_private_delivery_bypasses_group_chat_mapping(telegram):
"""私聊投递必须直接使用用户 ID,不能沿用该用户最近发言的群聊。"""
telegram._user_chat_mapping["10001"] = "group-1"
result = telegram.send_msg(
title="",
text="受保护消息",
userid="10001",
private_delivery=True,
)
assert result and result.get("success")
assert telegram.bot.send_message.call_args.kwargs["chat_id"] == "10001"
def test_telegram_plain_private_delivery_keeps_literal_text(telegram):
"""纯文本私聊不得解释或改写密钥中可能出现的 Markdown/HTML 字符。"""
literal_text = "G2A1_PROTECTED_MARKER_20260812\n**literal markdown**\n<img src=x>"
result = telegram.send_msg(
title="",
text=literal_text,
userid="10001",
private_delivery=True,
parse_mode="plain",
)
assert result and result.get("success")
send_kwargs = telegram.bot.send_message.call_args.kwargs
assert send_kwargs["text"] == literal_text
assert send_kwargs["parse_mode"] == ""
def test_send_msg_with_force_reply_uses_force_reply_when_no_buttons(telegram): def test_send_msg_with_force_reply_uses_force_reply_when_no_buttons(telegram):
"""无按钮时force_reply应生成Telegram ForceReply标记""" """无按钮时force_reply应生成Telegram ForceReply标记"""
result = telegram.send_msg( result = telegram.send_msg(
+19 -1
View File
@@ -106,6 +106,19 @@ def test_web_agent_event_publisher_coalesces_text_before_semantic_events():
assert max_depth == 2 assert max_depth == 2
def test_web_agent_event_publisher_rejects_events_after_close():
"""连接关闭后必须显式拒绝事件,避免把敏感结果误报为已交付。"""
async def scenario():
publisher = _WebAgentEventPublisher()
await publisher.aclose()
return publisher.publish(
{"type": "interaction-protected", "content": "secret"}
)
assert asyncio.run(scenario()) is False
def test_build_web_agent_session_id_is_stable_per_user_and_seed(): def test_build_web_agent_session_id_is_stable_per_user_and_seed():
"""同一用户和前端会话标识应生成稳定的服务端会话 ID。""" """同一用户和前端会话标识应生成稳定的服务端会话 ID。"""
user = SimpleNamespace(id=1, name="admin") user = SimpleNamespace(id=1, name="admin")
@@ -969,11 +982,15 @@ def test_web_agent_stream_drops_secret_result_after_disconnect():
client_session_id=payload.session_id, client_session_id=payload.session_id,
) )
delivery_results = []
async def finish_after_disconnect(**kwargs): async def finish_after_disconnect(**kwargs):
"""断线后继续完成只读任务,并尝试向已关闭发布器投递。""" """断线后继续完成只读任务,并尝试向已关闭发布器投递。"""
agent_started.set() agent_started.set()
await release_agent.wait() await release_agent.wait()
kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER") delivery_results.append(
kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER")
)
agent_completed.set() agent_completed.set()
async def scenario(): async def scenario():
@@ -999,6 +1016,7 @@ def test_web_agent_stream_drops_secret_result_after_disconnect():
body = asyncio.run(scenario()) body = asyncio.run(scenario())
assert '"type": "start"' in body assert '"type": "start"' in body
assert delivery_results == [False]
assert "cancel_on_waiter_cancel" not in process.await_args.kwargs assert "cancel_on_waiter_cancel" not in process.await_args.kwargs
save_snapshot.assert_not_called() save_snapshot.assert_not_called()
preserved_chat = AgentChatOper().get(session_id=session_id, user_id="1") preserved_chat = AgentChatOper().get(session_id=session_id, user_id="1")