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

314 lines
13 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, Optional, Type
from pydantic import BaseModel, Field, PrivateAttr
from app.agent.api.arguments import 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.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: Any = 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."
),
)
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, including versioned automatic media classification. "
"Read the matching domain Skill before the first call, then select operation_id, "
"parameters, and failure handling. For collection counts, use the smallest documented "
"page and read collection.total_count instead of querying the database after item "
"truncation. External MCP tools/list exposes one complete oneOf branch per operation. "
"Common contracts: subscription.find uses path_params.media_id and query.media_source; "
"subscription.list accepts only query.page and query.count; site.list accepts only "
"query.page, query.count, query.name, and query.status; download.add puts torrent_in "
"with title/enclosure beside media_source/media_id in body. Arbitrary URLs, commands, "
"and authentication endpoints 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())
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:
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,
)
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"]