mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-10 07:39:24 +08:00
357 lines
15 KiB
Python
357 lines
15 KiB
Python
"""MoviePilot 结构化 API 网关工具。"""
|
||
|
||
import json
|
||
from copy import deepcopy
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Optional, Type
|
||
|
||
from pydantic import BaseModel, Field, PrivateAttr
|
||
|
||
from app.agent.api.arguments import api_input_contract, canonical_api_arguments
|
||
from app.agent.api.executor import ApiExecutionContext, ApiExecutionError, MoviePilotApiExecutor
|
||
from app.agent.policy.api import 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
|
||
|
||
|
||
@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 网关的结构化输入参数。"""
|
||
|
||
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 placeholder values declared by the selected operation.",
|
||
)
|
||
query: Dict[str, Any] = Field(
|
||
default_factory=dict,
|
||
description="Query-string fields declared by the selected operation.",
|
||
)
|
||
body: JsonData = Field(
|
||
default=None,
|
||
description=(
|
||
"JSON request value declared by the selected operation and its loaded Skill contract. "
|
||
"Most operations use an object; a oneOf branch may require an exact scalar. "
|
||
"Keep objects and arrays, including nested file items, as native JSON values; "
|
||
"never JSON-encode them into strings."
|
||
),
|
||
)
|
||
|
||
|
||
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. "
|
||
"Arbitrary URLs, commands, authentication endpoints, headers, and tokens are forbidden."
|
||
)
|
||
require_admin: bool = False
|
||
args_schema: Type[BaseModel] = MoviePilotApiInput
|
||
|
||
_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_mcp_input_schema(self) -> dict[str, Any]:
|
||
"""返回包含全部白名单 operation 精确参数的 MCP JSON Schema。"""
|
||
return deepcopy(_load_api_mcp_input_schema())
|
||
|
||
def canonical_arguments(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||
"""用缓存的 operation 合同生成实际执行与持久指纹共用的参数。"""
|
||
validated = MoviePilotApiInput.model_validate(arguments).model_dump(mode="json")
|
||
return canonical_api_arguments(validated, _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 "")
|
||
if self._source == "api" 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 用户身份")
|
||
|
||
username = self._username
|
||
user = await self._data.users.async_get_by_name(username) if username else None
|
||
if user is None and not is_direct_user_channel and self._channel and raw_user_id:
|
||
try:
|
||
channel = NotificationChannel(self._channel)
|
||
except ValueError:
|
||
channel = 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 channel is not None
|
||
else None
|
||
)
|
||
if binding_keys:
|
||
username = await self.run_blocking(
|
||
"db",
|
||
self._data.users.find_name_by_bindings,
|
||
{key: raw_user_id for key in binding_keys},
|
||
)
|
||
user = await self._data.users.async_get_by_name(username) if username else None
|
||
if user is None and is_direct_user_channel and raw_user_id.isdigit():
|
||
return raw_user_id, self._username, bool(self._agent_context.get("is_admin"))
|
||
if user is None or not user.is_active:
|
||
raise ApiExecutionError("当前 Agent 身份未绑定有效的 MoviePilot 用户")
|
||
return str(user.id), user.name, bool(user.is_superuser)
|
||
|
||
async def _get_executor(
|
||
self,
|
||
*,
|
||
require_system_admin: bool = False,
|
||
) -> tuple[MoviePilotApiExecutor, bool]:
|
||
"""返回当前 operation 的执行器及其管理员身份事实。"""
|
||
if self._executor is not None:
|
||
return self._executor, await self.is_admin_user()
|
||
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,
|
||
)
|
||
),
|
||
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: 结构化业务结果或安全错误消息
|
||
"""
|
||
del kwargs
|
||
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}
|
||
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,
|
||
)
|
||
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"]
|