mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 02:05:13 +08:00
fix(agent): close secret confirmation delivery gaps (#6285)
This commit is contained in:
@@ -317,7 +317,6 @@ class _PendingSecretConfirmation:
|
||||
user_id: str
|
||||
channel: str
|
||||
source: str
|
||||
original_chat_id: str
|
||||
|
||||
|
||||
class MoviePilotAgent:
|
||||
@@ -339,7 +338,7 @@ class MoviePilotAgent:
|
||||
replay_mode: ReplyMode = ReplyMode.DISPATCH,
|
||||
allow_message_tools: bool = True,
|
||||
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.user_id = user_id
|
||||
@@ -771,7 +770,7 @@ class MoviePilotAgent:
|
||||
|
||||
def set_protected_output_callback(
|
||||
self,
|
||||
protected_output_callback: Optional[Callable[[str], None]],
|
||||
protected_output_callback: Optional[Callable[[str], Optional[bool]]],
|
||||
) -> None:
|
||||
"""更新仅供当前请求接收的受保护文本输出回调。"""
|
||||
self.protected_output_callback = protected_output_callback
|
||||
@@ -790,7 +789,7 @@ class MoviePilotAgent:
|
||||
"""判断当前渠道能否把密钥结果直接交付给原用户。"""
|
||||
if self.channel == MessageChannel.WebAgent.value:
|
||||
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.Feishu.value,
|
||||
}
|
||||
@@ -831,15 +830,6 @@ class MoviePilotAgent:
|
||||
self._pending_secret_confirmation = None
|
||||
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 (
|
||||
validated_arguments.get("group") or "all"
|
||||
)
|
||||
@@ -849,34 +839,73 @@ class MoviePilotAgent:
|
||||
"请在 5 分钟内回复“确认”继续,或回复“取消”放弃。"
|
||||
)
|
||||
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)
|
||||
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
|
||||
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):
|
||||
try:
|
||||
self.protected_output_callback(content)
|
||||
delivered = self.protected_output_callback(content)
|
||||
except Exception as e:
|
||||
logger.error(f"受保护输出回调失败: {e}")
|
||||
return
|
||||
|
||||
await AgentChain().async_post_message(
|
||||
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,
|
||||
)
|
||||
)
|
||||
return False
|
||||
return delivered is not False
|
||||
return await self._deliver_private_channel_message(content)
|
||||
|
||||
async def _handle_secret_confirmation_control(
|
||||
self,
|
||||
@@ -899,7 +928,6 @@ class MoviePilotAgent:
|
||||
pending.user_id != str(self.user_id or "")
|
||||
or pending.channel != str(self.channel or "")
|
||||
or pending.source != str(self.source or "")
|
||||
or pending.original_chat_id != str(self.original_chat_id or "")
|
||||
):
|
||||
return None
|
||||
if datetime.now() - pending.created_at > SECRET_CONFIRMATION_TTL:
|
||||
@@ -913,16 +941,37 @@ class MoviePilotAgent:
|
||||
await self._deliver_protected_output(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():
|
||||
return "当前入口不支持安全交付敏感设置,未执行读取。"
|
||||
|
||||
result = await pending.tool._run_confirmed(**pending.arguments)
|
||||
await self._deliver_protected_output(result)
|
||||
async def _execute_confirmed() -> str:
|
||||
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 "敏感设置确认已处理。"
|
||||
|
||||
def _build_policy_context(self) -> ToolPolicyContext:
|
||||
@@ -2166,7 +2215,7 @@ class _MessageTask:
|
||||
reply_mode: ReplyMode = ReplyMode.DISPATCH
|
||||
allow_message_tools: bool = True
|
||||
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
|
||||
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
|
||||
completion_future: Optional[asyncio.Future] = None
|
||||
@@ -2227,7 +2276,6 @@ class AgentManager:
|
||||
user_id: str,
|
||||
channel: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""判断指定用户是否可继续当前会话的敏感设置确认。"""
|
||||
agent = self.active_agents.get(session_id)
|
||||
@@ -2238,10 +2286,6 @@ class AgentManager:
|
||||
and str(agent.user_id) == str(user_id)
|
||||
and (channel is None or pending.channel == str(channel))
|
||||
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):
|
||||
@@ -2345,7 +2389,7 @@ class AgentManager:
|
||||
reply_mode: ReplyMode = ReplyMode.DISPATCH,
|
||||
allow_message_tools: bool = True,
|
||||
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,
|
||||
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
|
||||
wait_for_completion: bool = False,
|
||||
|
||||
@@ -17,6 +17,9 @@ from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
|
||||
|
||||
|
||||
POLICY_DENIED_MESSAGE = "当前宿主策略不允许执行该工具。"
|
||||
|
||||
|
||||
class AgentPolicyMiddleware(AgentMiddleware):
|
||||
"""观测进入本地 ToolNode 的 client-side 工具调用和结果。
|
||||
|
||||
@@ -103,16 +106,43 @@ class AgentPolicyMiddleware(AgentMiddleware):
|
||||
arguments = tool_call.get("args") or {}
|
||||
if not isinstance(arguments, dict):
|
||||
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(
|
||||
"start",
|
||||
self.orchestrator.start,
|
||||
context=self.context,
|
||||
tool=request.tool,
|
||||
tool=tool,
|
||||
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:
|
||||
result = await handler(request)
|
||||
result = await handler()
|
||||
except Exception as error:
|
||||
if observation is not None:
|
||||
call_policy_hook(
|
||||
@@ -129,7 +159,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
|
||||
observation,
|
||||
result,
|
||||
)
|
||||
return result
|
||||
return True, result
|
||||
|
||||
|
||||
__all__ = ["AgentPolicyMiddleware"]
|
||||
__all__ = ["AgentPolicyMiddleware", "POLICY_DENIED_MESSAGE"]
|
||||
|
||||
@@ -21,6 +21,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
|
||||
<confirmation_policy>
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
@@ -58,7 +58,10 @@ class QuerySystemSettingsInput(BaseModel):
|
||||
False,
|
||||
description=(
|
||||
"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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -84,10 +84,10 @@ class _WebAgentEventPublisher:
|
||||
"""返回本轮发布器观测到的最大积压深度。"""
|
||||
return self._max_depth
|
||||
|
||||
def publish(self, event: dict) -> None:
|
||||
"""发布事件;相邻文本会按时间或长度边界合并。"""
|
||||
def publish(self, event: dict) -> bool:
|
||||
"""发布事件;返回关闭状态以便受保护投递能准确报告失败。"""
|
||||
if self._disposed:
|
||||
return
|
||||
return False
|
||||
if event.get("type") == "delta":
|
||||
self._pending_delta += str(event.get("content") or "")
|
||||
if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS:
|
||||
@@ -98,10 +98,11 @@ class _WebAgentEventPublisher:
|
||||
WEB_AGENT_STREAM_COALESCE_SECONDS,
|
||||
self._flush_delta,
|
||||
)
|
||||
return
|
||||
return True
|
||||
|
||||
self._flush_delta()
|
||||
self._append_event(event)
|
||||
return True
|
||||
|
||||
async def get(self) -> dict:
|
||||
"""等待并返回下一条已排序事件。"""
|
||||
@@ -1955,7 +1956,6 @@ async def web_agent_stream(
|
||||
str(current_user.id),
|
||||
channel=MessageChannel.WebAgent.value,
|
||||
source=WEB_AGENT_SOURCE,
|
||||
original_chat_id=str(payload.original_chat_id or ""),
|
||||
)
|
||||
)
|
||||
protected_transport_supported = (
|
||||
@@ -2177,9 +2177,9 @@ async def web_agent_stream(
|
||||
_apply_web_agent_display_event(item, assistant_display_message)
|
||||
event_publisher.publish(item)
|
||||
|
||||
def protected_output_callback(content: str) -> None:
|
||||
def protected_output_callback(content: str) -> bool:
|
||||
"""将敏感文本封装为不进入普通展示快照的命名 SSE 事件。"""
|
||||
event_publisher.publish(
|
||||
return event_publisher.publish(
|
||||
{
|
||||
"type": "interaction-protected",
|
||||
"content": content,
|
||||
|
||||
@@ -323,7 +323,6 @@ class MessageChain(ChainBase):
|
||||
str(userid),
|
||||
channel=channel.value,
|
||||
source=source,
|
||||
original_chat_id=original_chat_id,
|
||||
):
|
||||
return False
|
||||
return self._handle_ai_message(
|
||||
|
||||
@@ -59,6 +59,10 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
|
||||
chat_id = None
|
||||
receive_id_type = "open_id" if userid else None
|
||||
|
||||
# 私聊投递只能按用户身份寻址,原会话 ID 可能属于群聊。
|
||||
if message.private_delivery and userid:
|
||||
return userid, None, None
|
||||
|
||||
# 回复类消息携带原会话 ID 时,必须发回原会话,
|
||||
# 否则群聊 @ 机器人的回复会错误地发送到机器人与用户的私聊窗口。
|
||||
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,
|
||||
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:
|
||||
result = client.send_notification(
|
||||
message=message,
|
||||
|
||||
@@ -761,6 +761,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
original_chat_id=original_chat_id,
|
||||
disable_web_page_preview=message.disable_web_page_preview,
|
||||
parse_mode=message.parse_mode,
|
||||
private_delivery=message.private_delivery,
|
||||
)
|
||||
if result and result.get("success"):
|
||||
return MessageResponse(
|
||||
|
||||
@@ -47,10 +47,13 @@ from app.utils.string import StringUtils # noqa: E402
|
||||
|
||||
TELEGRAM_PARSE_MODE_MARKDOWN = "MarkdownV2"
|
||||
TELEGRAM_PARSE_MODE_HTML = "HTML"
|
||||
TELEGRAM_PARSE_MODE_PLAIN = ""
|
||||
TELEGRAM_PARSE_MODE_ALIASES = {
|
||||
"markdownv2": TELEGRAM_PARSE_MODE_MARKDOWN,
|
||||
"mdv2": TELEGRAM_PARSE_MODE_MARKDOWN,
|
||||
"html": TELEGRAM_PARSE_MODE_HTML,
|
||||
"plain": TELEGRAM_PARSE_MODE_PLAIN,
|
||||
"text": TELEGRAM_PARSE_MODE_PLAIN,
|
||||
}
|
||||
|
||||
|
||||
@@ -295,10 +298,13 @@ class Telegram:
|
||||
@staticmethod
|
||||
def _normalize_parse_mode(parse_mode: Optional[str] = None) -> str:
|
||||
"""规范化 Telegram 消息格式类型。"""
|
||||
if not parse_mode:
|
||||
if parse_mode is None:
|
||||
return TELEGRAM_PARSE_MODE_MARKDOWN
|
||||
normalized = str(parse_mode).strip()
|
||||
if not normalized:
|
||||
return TELEGRAM_PARSE_MODE_PLAIN
|
||||
return TELEGRAM_PARSE_MODE_ALIASES.get(
|
||||
str(parse_mode).strip().lower(), TELEGRAM_PARSE_MODE_MARKDOWN
|
||||
normalized.lower(), TELEGRAM_PARSE_MODE_MARKDOWN
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -306,11 +312,18 @@ class Telegram:
|
||||
"""判断本次发送是否使用 Telegram 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
|
||||
def _format_title(title: Optional[str], parse_mode: Optional[str] = None) -> Optional[str]:
|
||||
"""按 parse_mode 生成 Telegram 标题文本。"""
|
||||
if not title:
|
||||
return None
|
||||
if Telegram._is_plain_parse_mode(parse_mode):
|
||||
return title.removesuffix("\n")
|
||||
if Telegram._is_html_parse_mode(parse_mode):
|
||||
return f"<b>{html_utils.escape(title).removesuffix(chr(10))}</b>"
|
||||
return f"**{standardize(title).removesuffix(chr(10))}**"
|
||||
@@ -318,6 +331,8 @@ class Telegram:
|
||||
@staticmethod
|
||||
def _format_link(label: str, link: str, parse_mode: Optional[str] = None) -> str:
|
||||
"""按 parse_mode 生成 Telegram 链接文本。"""
|
||||
if Telegram._is_plain_parse_mode(parse_mode):
|
||||
return f"{label}: {link}"
|
||||
if Telegram._is_html_parse_mode(parse_mode):
|
||||
return (
|
||||
f'<a href="{html_utils.escape(link, quote=True)}">'
|
||||
@@ -328,6 +343,8 @@ class Telegram:
|
||||
@staticmethod
|
||||
def _format_italic(text: str, parse_mode: Optional[str] = None) -> str:
|
||||
"""按 parse_mode 生成 Telegram 斜体文本。"""
|
||||
if Telegram._is_plain_parse_mode(parse_mode):
|
||||
return text
|
||||
if Telegram._is_html_parse_mode(parse_mode):
|
||||
return f"<i>{html_utils.escape(text)}</i>"
|
||||
return f"_{text}_"
|
||||
@@ -342,7 +359,10 @@ class Telegram:
|
||||
"""按 parse_mode 生成 Telegram 可发送文本。"""
|
||||
if not text:
|
||||
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 standardize(text)
|
||||
|
||||
@@ -595,6 +615,7 @@ class Telegram:
|
||||
disable_web_page_preview: Optional[bool] = None,
|
||||
stop_typing: bool = False,
|
||||
parse_mode: Optional[str] = None,
|
||||
private_delivery: bool = False,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
发送Telegram消息
|
||||
@@ -610,6 +631,7 @@ class Telegram:
|
||||
:param disable_web_page_preview: 是否禁用链接预览
|
||||
:param stop_typing: 发送完成后是否立即停止 typing
|
||||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||||
:param private_delivery: 是否绕过最近会话映射,直接以用户 ID 作为私聊目标
|
||||
:return: 包含 message_id, chat_id, success 的字典
|
||||
"""
|
||||
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)
|
||||
# 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:
|
||||
logger.warn("标题和内容不能同时为空")
|
||||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||||
@@ -836,7 +862,10 @@ class Telegram:
|
||||
return {"success": False}
|
||||
|
||||
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:
|
||||
"""
|
||||
确定目标聊天ID,使用用户映射确保回复到正确的聊天
|
||||
@@ -844,6 +873,10 @@ class Telegram:
|
||||
:param original_chat_id: 原消息的聊天ID
|
||||
:return: 目标聊天ID
|
||||
"""
|
||||
# 私聊投递以渠道用户 ID 为目标,最近会话映射可能指向群聊,不能参与解析。
|
||||
if private_delivery and userid:
|
||||
return str(userid)
|
||||
|
||||
# 1. 优先使用原消息的聊天ID (编辑消息场景)
|
||||
if original_chat_id:
|
||||
return original_chat_id
|
||||
@@ -1354,7 +1387,10 @@ class Telegram:
|
||||
ret = self.__send_short_message(image, caption,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
**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(
|
||||
image,
|
||||
caption,
|
||||
|
||||
@@ -262,9 +262,11 @@ class Notification(BaseModel):
|
||||
original_message_id: Optional[Union[str, int]] = None
|
||||
# 原消息的聊天ID,用于编辑消息
|
||||
original_chat_id: Optional[str] = None
|
||||
# 是否必须按用户身份投递到私聊,禁止回退原会话或最近会话映射
|
||||
private_delivery: bool = False
|
||||
# 是否禁用链接预览(仅Telegram支持)
|
||||
disable_web_page_preview: Optional[bool] = None
|
||||
# Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||||
# 消息文本格式;Telegram 支持 MarkdownV2、HTML、plain,飞书直发支持 plain
|
||||
parse_mode: Optional[str] = None
|
||||
# 是否写入消息历史
|
||||
save_history: bool = True
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.agent.prompt import PromptManager
|
||||
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsInput
|
||||
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 "/usr/local/bin/ffmpeg" not 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
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
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_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
@@ -288,7 +289,6 @@ def test_expired_confirmation_reaches_agent_expiry_receipt() -> None:
|
||||
"1",
|
||||
channel=MessageChannel.WebAgent.value,
|
||||
source="web-agent",
|
||||
original_chat_id="",
|
||||
)
|
||||
return await agent.process("确认")
|
||||
finally:
|
||||
@@ -324,7 +324,7 @@ def test_background_agent_refuses_secret_read_without_pending() -> None:
|
||||
|
||||
|
||||
def test_message_channel_receives_confirmation_prompt_once() -> None:
|
||||
"""TG/飞书应由宿主直接发送确认提示,不依赖图状态转成渠道输出。"""
|
||||
"""TG/飞书应先向用户私聊发送提示,再登记待确认操作。"""
|
||||
agent = MoviePilotAgent(
|
||||
session_id="session-secret",
|
||||
user_id="1",
|
||||
@@ -347,17 +347,79 @@ def test_message_channel_receives_confirmation_prompt_once() -> None:
|
||||
new=AsyncMock(return_value=True),
|
||||
), patch.object(
|
||||
agent,
|
||||
"send_agent_message",
|
||||
new=AsyncMock(),
|
||||
"_deliver_private_channel_message",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as send_message:
|
||||
prompt = asyncio.run(scenario())
|
||||
|
||||
send_message.assert_awaited_once_with(prompt)
|
||||
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:
|
||||
"""新请求不得覆盖 pending,错误交付目标也不得消费它。"""
|
||||
def test_message_channel_does_not_register_pending_when_private_delivery_fails() -> None:
|
||||
"""无法建立私聊时不得等待确认,更不能回退群聊投递结果。"""
|
||||
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(
|
||||
session_id="session-secret",
|
||||
user_id="1",
|
||||
@@ -367,6 +429,8 @@ def test_pending_secret_read_keeps_original_owner_and_action() -> None:
|
||||
original_chat_id="chat-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]:
|
||||
first = await agent._register_secret_confirmation(
|
||||
@@ -384,12 +448,254 @@ def test_pending_secret_read_keeps_original_owner_and_action() -> None:
|
||||
with (
|
||||
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
|
||||
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())
|
||||
|
||||
assert "TMDB_API_KEY" in first
|
||||
assert "已有待确认" in second
|
||||
assert result == "普通回复"
|
||||
load_value.assert_not_called()
|
||||
assert agent.has_pending_secret_confirmation() is True
|
||||
assert result == "敏感设置确认已处理。"
|
||||
load_value.assert_called_once()
|
||||
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()
|
||||
|
||||
@@ -373,7 +373,9 @@ def test_middleware_observation_failure_does_not_replace_success(
|
||||
) -> None:
|
||||
"""shadow start/finish 故障不能阻止 handler 或替换成功结果。"""
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.start.return_value = object()
|
||||
orchestrator.start.return_value = SimpleNamespace(
|
||||
decision=SimpleNamespace(allowed=True)
|
||||
)
|
||||
getattr(orchestrator, failed_phase).side_effect = RuntimeError(
|
||||
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:
|
||||
"""shadow fail hook 故障后仍必须抛出原始工具异常。"""
|
||||
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")
|
||||
middleware = AgentPolicyMiddleware(
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""fail-open 诊断只记录阶段和异常类型,不读取可能含凭据的异常文本。"""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
@@ -1155,6 +1155,45 @@ class TestFeishu(unittest.TestCase):
|
||||
self.assertEqual(response.message_id, "om_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):
|
||||
client = self._build_client()
|
||||
original_loop = object()
|
||||
@@ -1515,6 +1554,20 @@ class TestFeishu(unittest.TestCase):
|
||||
self.assertIsNone(userid)
|
||||
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):
|
||||
"""携带原会话上下文的回复应定向到原会话(群聊)。"""
|
||||
module = FeishuModule()
|
||||
|
||||
@@ -578,6 +578,39 @@ def test_telegram_module_plain_direct_message_keeps_userid_target():
|
||||
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):
|
||||
"""无按钮时force_reply应生成Telegram ForceReply标记"""
|
||||
result = telegram.send_msg(
|
||||
|
||||
@@ -106,6 +106,19 @@ def test_web_agent_event_publisher_coalesces_text_before_semantic_events():
|
||||
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():
|
||||
"""同一用户和前端会话标识应生成稳定的服务端会话 ID。"""
|
||||
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,
|
||||
)
|
||||
|
||||
delivery_results = []
|
||||
|
||||
async def finish_after_disconnect(**kwargs):
|
||||
"""断线后继续完成只读任务,并尝试向已关闭发布器投递。"""
|
||||
agent_started.set()
|
||||
await release_agent.wait()
|
||||
kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER")
|
||||
delivery_results.append(
|
||||
kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER")
|
||||
)
|
||||
agent_completed.set()
|
||||
|
||||
async def scenario():
|
||||
@@ -999,6 +1016,7 @@ def test_web_agent_stream_drops_secret_result_after_disconnect():
|
||||
body = asyncio.run(scenario())
|
||||
|
||||
assert '"type": "start"' in body
|
||||
assert delivery_results == [False]
|
||||
assert "cancel_on_waiter_cancel" not in process.await_args.kwargs
|
||||
save_snapshot.assert_not_called()
|
||||
preserved_chat = AgentChatOper().get(session_id=session_id, user_id="1")
|
||||
|
||||
Reference in New Issue
Block a user