"""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.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 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"]