Files
MoviePilot/app/agent/tools/impl/api.py

450 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MoviePilot 结构化 API 网关工具。"""
import json
from copy import deepcopy
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, Literal, Optional, Type, Union, cast
from langchain_core.tools import ToolException
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from app.agent.api.arguments import (
api_input_contract,
canonical_api_arguments,
coerce_string_body,
)
from app.agent.api.executor import ApiExecutionContext, ApiExecutionError, MoviePilotApiExecutor
from app.agent.policy.api import API_UNBOUND_READ_OPERATIONS, resolve_api_operation
from app.agent.policy.contracts import ExecutionOutcome, PrincipalRole
from app.agent.policy.sanitizer import summarize_input
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.result import inspect_tool_result
from app.agent.tools.tags import ToolTag
from app.schemas.common import JsonData
from app.schemas.types import NotificationChannel
_TOOL_MESSAGE_OPERATION_MAX_CHARS = 96
_TOOL_MESSAGE_PARAMETER_MAX_CHARS = 320
_SEARCH_TORRENTS_TOOL_TIMEOUT_SECONDS = 300.0
# API 网关顶层请求体只开放对象、数组和 system.upgrade.dev 的固定字符串。
MoviePilotApiBody = Union[Dict[str, JsonData], list[JsonData], Literal["dev"], None]
@lru_cache(maxsize=1)
def _load_api_mcp_input_schema() -> dict[str, Any]:
"""读取由业务 OpenAPI 生成并经漂移测试锁定的外部 MCP schema。"""
schema_path = (
Path(__file__).resolve().parents[2]
/ "policy"
/ "resources"
/ "api_mcp_schema.json"
)
payload = json.loads(schema_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict) or not isinstance(payload.get("oneOf"), list):
raise RuntimeError("moviepilot_api MCP schema 无效")
return payload
class MoviePilotApiInput(BaseModel): # type: ignore[misc]
"""MoviePilot API 网关的结构化输入参数。"""
model_config = ConfigDict(extra="forbid")
operation_id: str = Field(
...,
description=(
"Exact allowlisted MoviePilot operation ID selected from the loaded domain Skill. "
"Never supply a URL, authentication header, or API token."
),
)
path_params: Dict[str, Any] = Field(
default_factory=dict,
description="Route placeholders only, e.g. media.detail uses {\"media_id\": \"27205\"}.",
)
query: Dict[str, Any] = Field(
default_factory=dict,
description=(
"Query fields declared by this operation, never flattened at the top level. "
"Pagination is operation-specific: subscription.execution.list uses limit, not page/count."
),
)
body: MoviePilotApiBody = Field(
default=None,
description=(
"Request body declared by the selected operation and its loaded Skill contract. "
"Pass objects and arrays, including nested file items, as native JSON values; "
"pass null only when the selected operation allows it. "
"The only string body is the exact value 'dev' for system.upgrade.dev."
),
)
@field_validator("body", mode="before") # type: ignore[misc]
@classmethod
def _restore_json_string_body(cls, value: Any) -> Any:
"""模型在联合类型 schema 下可能把 body 编码为 JSON 字符串,入模前还原。"""
return coerce_string_body(value)
class MoviePilotApiTool(MoviePilotTool):
"""
MoviePilot 结构化 API 网关。
网关只接受固定 operation ID 和结构化参数,由宿主注册表解析为固定 API
方法与路径;模型不能提供 URL、认证头、令牌或任意 HTTP 方法。
"""
name: str = "moviepilot_api"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
ToolTag.Subscription,
ToolTag.Download,
ToolTag.Site,
ToolTag.Plugin,
]
description: str = (
"Call allowlisted MoviePilot business APIs through operation-specific input contracts. "
"Load the relevant domain Skill before calling and use operation error feedback to correct inputs. "
"The ONLY top-level keys are operation_id, path_params, query, body. "
"Examples (replace sample IDs with IDs from prior results): "
'{"operation_id":"media.detail","path_params":{"media_id":"27205"},'
'"query":{"media_source":"tmdb","type_name":"电影"}}; '
'{"operation_id":"subscription.execution.list","query":{"limit":10}}; '
'{"operation_id":"site.rss","query":{"page":1,"count":20}}. '
"Never invent filters or copy pagination fields from another operation. "
"Arbitrary URLs, commands, authentication endpoints, headers, and tokens are forbidden."
)
require_admin: bool = False
args_schema: Type[BaseModel] = MoviePilotApiInput
handle_tool_error: bool = True
_executor: Optional[MoviePilotApiExecutor] = PrivateAttr(default=None)
def __init__(
self,
session_id: str,
user_id: str,
*,
executor: Optional[MoviePilotApiExecutor] = None,
**kwargs: Any,
) -> None:
"""注入可测试的固定 API 执行器。"""
super().__init__(session_id=session_id, user_id=user_id, **kwargs)
self._executor = executor
def get_tool_message(self, **kwargs: Any) -> Optional[str]:
"""生成包含脱敏、有界主要参数的结构化 API 调用提示。"""
operation_id = summarize_input(
kwargs.get("operation_id") or "未知操作",
max_chars=_TOOL_MESSAGE_OPERATION_MAX_CHARS,
)
message = f"调用 MoviePilot API:{operation_id}"
parameters = {
field_name: field_value
for field_name in ("path_params", "query", "body")
if (field_value := kwargs.get(field_name)) is not None
and not (isinstance(field_value, dict) and not field_value)
}
if not parameters:
return message
parameter_summary = summarize_input(
parameters,
max_chars=_TOOL_MESSAGE_PARAMETER_MAX_CHARS,
)
return f"{message},主要参数:{parameter_summary}"
def _get_run_timeout_seconds(self, **kwargs: Any) -> Optional[float]:
"""将 search.torrents 的总工具等待时间限制为五分钟。"""
if kwargs.get("operation_id") == "search.torrents":
return _SEARCH_TORRENTS_TOOL_TIMEOUT_SECONDS
return super()._get_run_timeout_seconds(**kwargs)
def get_mcp_input_schema(self) -> dict[str, Any]:
"""返回包含全部白名单 operation 精确参数的 MCP JSON Schema。"""
return deepcopy(_load_api_mcp_input_schema())
@property
def tool_call_schema(self) -> Type[BaseModel]:
"""本工具没有注入参数,直接保留原模型,防止子集投影丢失 extra=forbid。"""
return MoviePilotApiInput
def _parse_input(
self, tool_input: Union[str, dict[str, Any]], tool_call_id: Optional[str],
) -> Union[str, dict[str, Any]]:
"""在 LangChain 解析前校验原始参数,让只读调用也能收到安全纠错回执。"""
if isinstance(tool_input, dict):
operation_id = str(tool_input.get("operation_id") or "")
try:
self.canonical_arguments(tool_input)
except (TypeError, ValueError) as error:
raise ToolException(self._invalid_input_result(operation_id, error)) from error
return cast(Union[str, dict[str, Any]], super()._parse_input(tool_input, tool_call_id))
def canonical_arguments(self, arguments: dict[str, Any]) -> dict[str, Any]:
"""用缓存的 operation 合同生成实际执行与持久指纹共用的参数。"""
return canonical_api_arguments(arguments, _load_api_mcp_input_schema())
def get_operation_input_contract(self, operation_id: str) -> dict[str, Any]:
"""返回单个 operation 的有界参数合同,供失败回执指导模型重试。"""
return api_input_contract(operation_id, _load_api_mcp_input_schema())
def _invalid_input_result(self, operation_id: str, error: Exception) -> str:
"""把参数合同错误转换为不包含原始请求值的模型纠错回执。"""
contract = self.get_operation_input_contract(operation_id)
return json.dumps(
{
"success": False,
"error": "invalid_input",
"operation_id": operation_id,
"message": (
f"{operation_id} 的输入不符合已声明合同({str(error)})。"
"请按 input_contract 只提交允许字段并补齐 required 字段后重试。"
),
"input_contract": contract,
},
ensure_ascii=False,
)
async def _resolve_superuser_integration_identity(
self,
) -> tuple[str, Optional[str], bool]:
"""为已验证的管理员集成解析一个真实持久化超级管理员身份。"""
from app.application.security.auth import build_superuser_token_payload
payload = await self.run_blocking(
"db",
build_superuser_token_payload,
)
if payload.sub is None:
raise ApiExecutionError("管理员集成身份没有持久化用户 ID")
return str(payload.sub), payload.username, bool(payload.super_user)
async def _resolve_api_identity(
self,
*,
require_system_admin: bool = False,
) -> tuple[str, Optional[str], bool]:
"""把 Web、渠道或集成身份解析为真实 MoviePilot API 用户身份。"""
raw_user_id = str(self._user_id or "")
is_authenticated_integration = self._source == "api" or (
self._channel == NotificationChannel.Web.value
and self._source in {"openai", "openai.responses", "anthropic"}
)
if is_authenticated_integration and bool(self._agent_context.get("is_admin")):
return await self._resolve_superuser_integration_identity()
direct_user_channels = {
NotificationChannel.Web.value,
NotificationChannel.WebAgent.value,
}
is_direct_user_channel = self._channel in direct_user_channels
if (
require_system_admin
and bool(self._agent_context.get("is_admin"))
and not is_direct_user_channel
):
# 通知渠道管理员延续旧管理员工具语义,但只在管理员 operation
# 上借用系统管理员集成身份;普通 operation 仍解析其绑定用户。
return await self._resolve_superuser_integration_identity()
if self._data is None:
if is_direct_user_channel and raw_user_id.isdigit():
return raw_user_id, self._username, bool(self._agent_context.get("is_admin"))
raise ApiExecutionError("Agent 数据上下文未装配,无法解析 API 用户身份")
if is_direct_user_channel:
# Web 入口的持久化用户 ID 是事实源,旧会话用户名不能切换到同名新账号。
user = await self._data.users.async_get_by_id(int(raw_user_id)) if raw_user_id.isdigit() else None
elif not self._channel and not self._source:
# 无渠道的后台任务使用宿主持久化的用户名,保留定时任务的执行身份。
user = await self._data.users.async_get_by_name(self._username) if self._username else None
else:
user = await self._resolve_channel_api_user(raw_user_id)
if user is None:
raise ApiExecutionError(self._identity_unavailable_message())
if not user.is_active:
raise ApiExecutionError("当前 MoviePilot 用户已停用,请联系管理员启用该用户后重试。重新绑定不能解除停用。")
return str(user.id), user.name, bool(user.is_superuser)
def _identity_unavailable_message(self) -> str:
"""按真实前端入口提示绑定步骤;查询仅能确定没有唯一启用绑定,不臆断具体原因。"""
labels = {
NotificationChannel.Telegram.value: "Telegram用户",
NotificationChannel.Discord.value: "Discord用户",
NotificationChannel.Wechat.value: "企业微信用户",
NotificationChannel.Feishu.value: "飞书用户",
NotificationChannel.WechatClawBot.value: "微信 ClawBot 用户",
NotificationChannel.Slack.value: "Slack用户",
NotificationChannel.VoceChat.value: "VoceChat用户",
NotificationChannel.SynologyChat.value: "SynologyChat用户",
NotificationChannel.QQ.value: "QQ 用户 ID 或 QQ 用户 OpenID(填写与当前发送者 ID 对应的一项)",
}
label = labels.get(self._channel or "")
if label is None:
if self._channel in {NotificationChannel.Web.value, NotificationChannel.WebAgent.value}:
return "当前登录身份未对应有效的 MoviePilot 用户,请重新登录;若账号已删除,请联系管理员。此入口无需绑定通知渠道。"
return "当前任务身份未对应有效的 MoviePilot 用户,请联系管理员检查任务所属账号及身份配置。"
sender_id = json.dumps(str(self._user_id or ""), ensure_ascii=False)
return (
"当前渠道账号未绑定唯一且已启用的 MoviePilot 用户,可能尚未绑定、绑定用户已停用或存在重复绑定。"
f"请登录要使用的 MoviePilot 账号,打开右上角头像 → 个人信息 → 账号绑定,在「{label}」中填写"
f"当前渠道发送者 ID {sender_id},然后保存并重试。请填写完整 ID,不要填写昵称、群 ID 或机器人 ID。"
"若已经填写,请管理员确认该用户已启用,且同一渠道 ID 没有绑定到多个启用用户。"
)
async def _resolve_channel_api_user(self, raw_user_id: str) -> Any:
"""只按渠道稳定 ID 查找绑定用户,昵称和展示用户名不构成认证依据。"""
data = self._data
if data is None:
return None
try:
channel = NotificationChannel(self._channel)
except (TypeError, ValueError):
return None
binding_keys = {
NotificationChannel.Telegram: ("telegram_userid",),
NotificationChannel.Discord: ("discord_userid",),
NotificationChannel.Wechat: ("wechat_userid",),
NotificationChannel.Feishu: ("feishu_userid", "feishu_openid"),
NotificationChannel.WechatClawBot: ("wechatclawbot_userid",),
NotificationChannel.Slack: ("slack_userid",),
NotificationChannel.VoceChat: ("vocechat_userid",),
NotificationChannel.SynologyChat: ("synologychat_userid",),
NotificationChannel.QQ: ("qq_userid", "qq_openid"),
}.get(channel)
if not binding_keys or not raw_user_id:
return None
# userid/openid 是候选字段,仓储必须拒绝匹配到多个用户的歧义绑定。
username = await self.run_blocking(
"db", data.users.find_name_by_bindings,
{key: raw_user_id for key in binding_keys}, match_any=True,
)
return await data.users.async_get_by_name(username) if username else None
async def _get_executor(
self,
*,
require_system_admin: bool = False,
public_read_only: bool = False,
) -> tuple[MoviePilotApiExecutor, bool]:
"""解析执行身份;公共资料使用只能执行白名单读取的宿主认证通道。"""
if self._executor is not None:
return self._executor, await self.is_admin_user()
if public_read_only:
user_id, username, is_admin = await self._resolve_superuser_integration_identity()
else:
user_id, username, is_admin = await self._resolve_api_identity(
require_system_admin=require_system_admin,
)
return (
MoviePilotApiExecutor(
context=ApiExecutionContext(
user_id=user_id,
username=username,
is_admin=is_admin,
session_id=self._session_id,
channel=self._channel,
source=self._source,
public_read_only=public_read_only,
)
),
is_admin,
)
async def run( # type: ignore[override]
self,
operation_id: str,
path_params: Optional[Dict[str, Any]] = None,
query: Optional[Dict[str, Any]] = None,
body: Any = None,
**kwargs: Any,
) -> str:
"""
执行一个白名单 MoviePilot API 操作。
:param operation_id: 稳定 API 操作标识
:param path_params: 路径参数
:param query: 查询参数
:param body: JSON 请求体
:return: 结构化业务结果或安全错误消息
"""
spec = resolve_api_operation(operation_id)
if spec is None:
return json.dumps(
{
"success": False,
"error": "unknown_operation",
"message": f"未允许的 MoviePilot API 操作:{operation_id}",
},
ensure_ascii=False,
)
try:
arguments: dict[str, Any] = {"operation_id": operation_id, **kwargs}
if path_params is not None:
arguments["path_params"] = path_params
if query is not None:
arguments["query"] = query
if body is not None:
arguments["body"] = body
self.canonical_arguments(arguments)
except (TypeError, ValueError) as error:
return self._invalid_input_result(operation_id, error)
try:
requires_system_admin = spec.required_role is PrincipalRole.SYSTEM_ADMIN
executor, is_admin = await self._get_executor(
require_system_admin=requires_system_admin,
public_read_only=operation_id in API_UNBOUND_READ_OPERATIONS,
)
if requires_system_admin and not is_admin:
return json.dumps(
{
"success": False,
"error": "permission_denied",
"message": "This MoviePilot API operation requires a system administrator.",
},
ensure_ascii=False,
)
result = await executor.execute(
operation_id,
path_params=path_params,
query=query,
body=body,
)
try:
payload = json.loads(result)
except (TypeError, ValueError):
payload = None
if (
isinstance(payload, dict)
and payload.get("error") == "api_error"
and payload.get("status_code") in {400, 422}
):
payload["operation_id"] = operation_id
payload["message"] = (
f"{operation_id} 被 API 拒绝,请根据 input_contract 修正输入后重试。"
)
payload["input_contract"] = self.get_operation_input_contract(operation_id)
result = json.dumps(payload, ensure_ascii=False)
if operation_id == "scheduler.run" and inspect_tool_result(result) is ExecutionOutcome.SUCCEEDED:
payload = json.loads(result)
if isinstance(payload, dict) and payload.get("success") is True:
payload["execution_outcome"] = "pending"
return json.dumps(payload, ensure_ascii=False)
return result
except ApiExecutionError as error:
failure: dict[str, Any] = {
"success": False,
"error": "operation_unavailable",
"message": str(error),
}
if error.external_may_continue:
failure["execution_outcome"] = "unknown"
failure["message"] += ";操作可能已生效,请先只读核验实际状态,避免重复执行。"
return json.dumps(
failure,
ensure_ascii=False,
)
__all__ = ["MoviePilotApiInput", "MoviePilotApiTool"]