From d7ed3aaeca840a742c724bffab60a708e4020a85 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 13 Mar 2026 21:09:40 +0800 Subject: [PATCH 01/10] feat(api): add Tool Invoke API for external tool execution Add GET /api/v1/tools and POST /api/v1/tools/invoke endpoints to allow external systems (e.g. Naga) to discover and invoke Undefined's tools. Supports synchronous response and optional async webhook callback. - APIConfig: add 6 tool_invoke_* fields (enabled, expose, allow/denylist, timeouts) - Three-layer tool filtering: denylist > allowlist > expose scope - RequestContext + resource injection for proper tool execution - WebUI proxy routes for /api/runtime/tools and /api/runtime/tools/invoke - Update docs/openapi.md with full API reference, examples, and troubleshooting - Add 27 tests covering config parsing, filtering logic, invocation, and callbacks Co-Authored-By: Claude Opus 4.6 (1M context) --- config.toml.example | 18 + docs/openapi.md | 197 +++++++++++ src/Undefined/api/app.py | 402 +++++++++++++++++++++++ src/Undefined/config/loader.py | 24 ++ src/Undefined/config/models.py | 6 + src/Undefined/webui/routes/_runtime.py | 36 ++ tests/test_config_api.py | 51 +++ tests/test_runtime_api_tool_invoke.py | 438 +++++++++++++++++++++++++ 8 files changed, 1172 insertions(+) create mode 100644 tests/test_runtime_api_tool_invoke.py diff --git a/config.toml.example b/config.toml.example index 0f9c6083..6550dfac 100644 --- a/config.toml.example +++ b/config.toml.example @@ -733,6 +733,24 @@ auth_key = "changeme" # zh: 是否暴露 OpenAPI 文档(/openapi.json)。 # en: Expose OpenAPI document (/openapi.json). openapi_enabled = true +# zh: 是否启用工具调用 API(POST /api/v1/tools/invoke)。 +# en: Enable tool invocation API (POST /api/v1/tools/invoke). +tool_invoke_enabled = false +# zh: 暴露的工具范围。可选 "tools"、"toolsets"、"tools+toolsets"、"agents"、"all"。 +# en: Tool exposure scope. Options: "tools", "toolsets", "tools+toolsets", "agents", "all". +tool_invoke_expose = "tools+toolsets" +# zh: 工具白名单(非空时覆盖 expose 规则)。 +# en: Tool allowlist (overrides expose when non-empty). +tool_invoke_allowlist = [] +# zh: 工具黑名单(始终优先于白名单和 expose)。 +# en: Tool denylist (always takes priority). +tool_invoke_denylist = [] +# zh: 单次工具调用超时(秒)。 +# en: Per-invocation timeout in seconds. +tool_invoke_timeout = 120 +# zh: 回调请求超时(秒)。 +# en: Callback request timeout in seconds. +tool_invoke_callback_timeout = 10 # zh: 认知记忆系统配置。启用后需配置 [models.embedding],其余参数均有合理默认值。 # en: Cognitive memory system. Requires [models.embedding] when enabled. All other params have sensible defaults. diff --git a/docs/openapi.md b/docs/openapi.md index 29e9f58d..f509e8fe 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -20,12 +20,26 @@ host = "127.0.0.1" port = 8788 auth_key = "changeme" openapi_enabled = true + +# 工具调用 API +tool_invoke_enabled = false +tool_invoke_expose = "tools+toolsets" +tool_invoke_allowlist = [] +tool_invoke_denylist = [] +tool_invoke_timeout = 120 +tool_invoke_callback_timeout = 10 ``` - `enabled`:是否启动 Runtime API。 - `host` / `port`:监听地址和端口。 - `auth_key`:API 鉴权密钥(请求头 `X-Undefined-API-Key`)。 - `openapi_enabled`:是否开放 `/openapi.json`。 +- `tool_invoke_enabled`:是否启用工具调用 API(默认关闭,需显式开启)。 +- `tool_invoke_expose`:暴露的工具范围(`tools` / `toolsets` / `tools+toolsets` / `agents` / `all`)。 +- `tool_invoke_allowlist`:工具白名单(非空时覆盖 `expose` 规则)。 +- `tool_invoke_denylist`:工具黑名单(始终优先)。 +- `tool_invoke_timeout`:单次调用超时(秒)。 +- `tool_invoke_callback_timeout`:回调请求超时(秒)。 默认值: @@ -34,6 +48,12 @@ openapi_enabled = true - `port = 8788` - `auth_key = changeme` - `openapi_enabled = true` +- `tool_invoke_enabled = false` +- `tool_invoke_expose = tools+toolsets` +- `tool_invoke_allowlist = []` +- `tool_invoke_denylist = []` +- `tool_invoke_timeout = 120` +- `tool_invoke_callback_timeout = 10` 建议第一时间修改 `auth_key`,不要在公网直接暴露该端口。 @@ -187,6 +207,158 @@ curl http://127.0.0.1:8788/openapi.json - 用于读取虚拟私聊 `system#42` 的历史记录(只读)。 - 返回中包含 `role/content/timestamp`,用于 WebUI 自动恢复会话视图。 +### 工具调用 API + +需要在配置中显式开启 `tool_invoke_enabled = true`。未开启时所有工具调用端点返回 `403`。 + +#### 列出可用工具 + +- `GET /api/v1/tools` + +返回经 `expose` / `allowlist` / `denylist` 三层过滤后的可用工具列表,每个条目为 OpenAI function calling 格式的 schema。 + +响应示例: + +```json +{ + "count": 5, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "获取当前系统时间", + "parameters": { "type": "object", "properties": { ... } } + } + } + ] +} +``` + +#### 调用指定工具 + +- `POST /api/v1/tools/invoke` + +请求体: + +```json +{ + "tool_name": "scheduler.create_schedule_task", + "args": { "description": "...", "cron": "0 9 * * *" }, + "context": { + "request_type": "group", + "group_id": 123456, + "user_id": 789, + "sender_id": 789 + }, + "callback": { + "enabled": true, + "url": "https://example.com/callback", + "headers": { "X-Secret": "xxx" } + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `tool_name` | 是 | 工具全名(须在已过滤的可用列表中) | +| `args` | 是 | 工具参数(JSON 对象) | +| `context` | 否 | 请求上下文,不传时使用 `request_type="api"` 的虚拟上下文 | +| `callback` | 否 | 回调配置,启用后异步执行并将结果 POST 到 webhook URL | + +`context` 子字段: + +| 子字段 | 类型 | 说明 | +|--------|------|------| +| `request_type` | `string` | 请求来源类型,默认 `"api"`;也可传 `"group"` 或 `"private"` 模拟群聊/私聊上下文 | +| `group_id` | `int` | 群号(`request_type="group"` 时使用) | +| `user_id` | `int` | 用户 QQ 号(工具根据此值查询历史/侧写等) | +| `sender_id` | `int` | 发送者 QQ 号(用于权限判断) | + +`callback` 子字段: + +| 子字段 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `enabled` | `bool` | 是 | 是否启用回调 | +| `url` | `string` | 是(当 enabled=true) | webhook URL(须 HTTPS,回环地址可 HTTP) | +| `headers` | `object` | 否 | 自定义回调请求头(键值对) | + +**同步响应**(无回调): + +```json +{ + "ok": true, + "request_id": "uuid", + "tool_name": "get_current_time", + "result": "2026-03-13T12:00:00+08:00", + "duration_ms": 15.2 +} +``` + +**异步响应**(启用回调): + +```json +{ + "ok": true, + "request_id": "uuid", + "tool_name": "get_current_time", + "status": "accepted" +} +``` + +回调 POST 到 webhook URL 的 body: + +```json +{ + "request_id": "uuid", + "tool_name": "get_current_time", + "ok": true, + "result": "...", + "duration_ms": 15.2, + "error": null +} +``` + +**错误响应**(工具执行失败或超时): + +```json +{ + "ok": false, + "request_id": "uuid", + "tool_name": "get_current_time", + "error": "Execution timed out after 120s", + "duration_ms": 120001.5 +} +``` + +#### 错误状态码 + +| 状态码 | 含义 | +|--------|------| +| `400` | 请求体格式错误(缺少 `tool_name`、`args` 非对象、回调 URL 不合法等) | +| `401` | 鉴权失败(`X-Undefined-API-Key` 缺失或不匹配) | +| `403` | 工具调用 API 未启用(`tool_invoke_enabled = false`) | +| `404` | 指定的工具不在可用列表中(被 `denylist` / `expose` 过滤掉) | + +#### 工具过滤逻辑 + +过滤优先级:`denylist` > `allowlist` > `expose`。 + +- `denylist` 非空时,先排除匹配项。 +- `allowlist` 非空时,仅保留匹配项(忽略 `expose`)。 +- 否则按 `expose` 范围过滤: + - `tools`:仅基础工具(名称不含 `.`) + - `toolsets`:仅工具集工具(名称含 `.` 且非 `mcp.` 前缀) + - `tools+toolsets`:基础工具 + 工具集(默认) + - `agents`:仅 Agent + - `all`:全部(不含 anthropic_skills) + +#### 回调 URL 要求 + +- 支持 HTTP 和 HTTPS(scheme 必须为 `http://` 或 `https://`,不接受 `ftp://` 等其他协议)。 +- 回调超时由 `tool_invoke_callback_timeout` 独立控制。 +- 回调失败不影响工具调用的执行结果,仅记录日志 `[ToolInvoke] 回调失败`。 + ## 5. cURL 示例 ```bash @@ -206,6 +378,21 @@ curl -N -H "X-Undefined-API-Key: $KEY" \ -H "Accept: text/event-stream" \ -d '{"message":"你好","stream":true}' \ "$API/api/v1/chat" + +# 列出可用工具(需 tool_invoke_enabled = true) +curl -H "X-Undefined-API-Key: $KEY" "$API/api/v1/tools" + +# 同步调用工具 +curl -X POST -H "X-Undefined-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{"tool_name":"get_current_time","args":{"format":"iso"}}' \ + "$API/api/v1/tools/invoke" + +# 带回调的异步调用 +curl -X POST -H "X-Undefined-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{"tool_name":"get_current_time","args":{},"callback":{"enabled":true,"url":"https://webhook.site/xxx"}}' \ + "$API/api/v1/tools/invoke" ``` ## 6. WebUI 代理调用(推荐) @@ -221,6 +408,8 @@ WebUI 不直接在前端暴露 `auth_key`,而是通过后端代理访问主进 - `POST /api/runtime/chat` - `GET /api/runtime/chat/history` - `GET /api/runtime/openapi` +- `GET /api/runtime/tools` +- `POST /api/runtime/tools/invoke` WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 Header。 `/api/runtime/chat` 代理超时为 `480s`,并透传 SSE keep-alive。 @@ -235,3 +424,11 @@ WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 Header - 主进程未启动或监听地址/端口配置不一致。 - `openapi.json` 返回 `404`: - `[api].openapi_enabled = false`。 +- `/api/v1/tools` 或 `/api/v1/tools/invoke` 返回 `403`: + - `[api].tool_invoke_enabled = false`,需在配置中显式设为 `true`。 +- `/api/v1/tools/invoke` 返回 `404`(Tool not available): + - 工具被 `denylist` 排除,或不在 `allowlist` / `expose` 范围内。 + - 使用 `GET /api/v1/tools` 查看当前实际可用的工具列表。 +- 回调请求失败(日志 `[ToolInvoke] 回调失败`): + - 检查回调 URL 是否可达、证书是否有效。 + - 可通过 `tool_invoke_callback_timeout` 调整超时。 diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 67efc5c7..62f0b8dd 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -7,6 +7,7 @@ import socket import sys import time +import uuid as _uuid from contextlib import suppress from dataclasses import dataclass from datetime import datetime @@ -238,6 +239,19 @@ def _registry_summary(registry: Any) -> dict[str, Any]: } +def _validate_callback_url(url: str) -> str | None: + """校验回调 URL,返回错误信息或 None 表示通过。""" + parsed = urlsplit(url) + scheme = (parsed.scheme or "").lower() + + if scheme not in ("http", "https"): + return "callback.url must use http or https" + + return None + + return None + + async def _probe_http_endpoint( *, name: str, @@ -415,6 +429,25 @@ def _build_openapi_spec(ctx: RuntimeAPIContext) -> dict[str, Any]: "/api/v1/chat/history": { "get": {"summary": "Get virtual private chat history for WebUI"} }, + "/api/v1/tools": { + "get": { + "summary": "List available tools", + "description": ( + "Returns currently available tools filtered by " + "tool_invoke_expose / allowlist / denylist config. " + "Each item follows the OpenAI function calling schema." + ), + } + }, + "/api/v1/tools/invoke": { + "post": { + "summary": "Invoke a tool", + "description": ( + "Execute a specific tool by name. Supports synchronous " + "response and optional async webhook callback." + ), + } + }, }, } @@ -493,6 +526,8 @@ async def _auth_middleware( ), web.post("/api/v1/chat", self._chat_handler), web.get("/api/v1/chat/history", self._chat_history_handler), + web.get("/api/v1/tools", self._tools_list_handler), + web.post("/api/v1/tools/invoke", self._tools_invoke_handler), ] ) return app @@ -1058,3 +1093,370 @@ async def _capture_private_message_stream(user_id: int, message: str) -> None: await response.write_eof() return response + + # ------------------------------------------------------------------ + # Tool Invoke API + # ------------------------------------------------------------------ + + def _get_filtered_tools(self) -> list[dict[str, Any]]: + """按配置过滤可用工具,返回 OpenAI function calling schema 列表。""" + cfg = self._ctx.config_getter() + api_cfg = cfg.api + ai = self._ctx.ai + if ai is None: + return [] + + tool_reg = getattr(ai, "tool_registry", None) + agent_reg = getattr(ai, "agent_registry", None) + + all_schemas: list[dict[str, Any]] = [] + if tool_reg is not None: + all_schemas.extend(tool_reg.get_tools_schema()) + if agent_reg is not None: + all_schemas.extend(agent_reg.get_agents_schema()) + + denylist: set[str] = set(api_cfg.tool_invoke_denylist) + allowlist: set[str] = set(api_cfg.tool_invoke_allowlist) + expose = api_cfg.tool_invoke_expose + + def _get_name(schema: dict[str, Any]) -> str: + func = schema.get("function", {}) + return str(func.get("name", "")) + + # 1. 先排除黑名单 + if denylist: + all_schemas = [s for s in all_schemas if _get_name(s) not in denylist] + + # 2. 白名单非空时仅保留匹配项 + if allowlist: + return [s for s in all_schemas if _get_name(s) in allowlist] + + # 3. 按 expose 过滤 + if expose == "all": + return all_schemas + + # 收集 agent 名称集合 + agent_names: set[str] = set() + if agent_reg is not None: + for schema in agent_reg.get_agents_schema(): + name = _get_name(schema) + if name: + agent_names.add(name) + + def _is_tool(name: str) -> bool: + return "." not in name and name not in agent_names + + def _is_toolset(name: str) -> bool: + return "." in name and not name.startswith("mcp.") + + filtered: list[dict[str, Any]] = [] + for schema in all_schemas: + name = _get_name(schema) + if not name: + continue + if expose == "tools" and _is_tool(name): + filtered.append(schema) + elif expose == "toolsets" and _is_toolset(name): + filtered.append(schema) + elif expose == "tools+toolsets" and (_is_tool(name) or _is_toolset(name)): + filtered.append(schema) + elif expose == "agents" and name in agent_names: + filtered.append(schema) + + return filtered + + async def _tools_list_handler(self, request: web.Request) -> Response: + _ = request + cfg = self._ctx.config_getter() + if not cfg.api.tool_invoke_enabled: + return _json_error("Tool invoke API is disabled", status=403) + + tools = self._get_filtered_tools() + return web.json_response({"count": len(tools), "tools": tools}) + + async def _tools_invoke_handler(self, request: web.Request) -> Response: + cfg = self._ctx.config_getter() + if not cfg.api.tool_invoke_enabled: + return _json_error("Tool invoke API is disabled", status=403) + + try: + body = await request.json() + except Exception: + return _json_error("Invalid JSON", status=400) + + if not isinstance(body, dict): + return _json_error("Request body must be a JSON object", status=400) + + tool_name = str(body.get("tool_name", "") or "").strip() + if not tool_name: + return _json_error("tool_name is required", status=400) + + args = body.get("args") + if not isinstance(args, dict): + return _json_error("args must be a JSON object", status=400) + + # 验证工具是否在允许列表中 + filtered_tools = self._get_filtered_tools() + available_names: set[str] = set() + for schema in filtered_tools: + func = schema.get("function", {}) + name = str(func.get("name", "")) + if name: + available_names.add(name) + + if tool_name not in available_names: + caller_ip = request.remote or "unknown" + logger.warning( + "[ToolInvoke] 请求拒绝: tool=%s reason=not_available caller_ip=%s", + tool_name, + caller_ip, + ) + return _json_error(f"Tool '{tool_name}' is not available", status=404) + + # 解析回调配置 + callback_cfg = body.get("callback") + use_callback = False + callback_url = "" + callback_headers: dict[str, str] = {} + if isinstance(callback_cfg, dict) and _to_bool(callback_cfg.get("enabled")): + callback_url = str(callback_cfg.get("url", "") or "").strip() + if not callback_url: + return _json_error( + "callback.url is required when callback is enabled", + status=400, + ) + url_error = _validate_callback_url(callback_url) + if url_error: + return _json_error(url_error, status=400) + raw_headers = callback_cfg.get("headers") + if isinstance(raw_headers, dict): + callback_headers = {str(k): str(v) for k, v in raw_headers.items()} + use_callback = True + + request_id = _uuid.uuid4().hex + caller_ip = request.remote or "unknown" + logger.info( + "[ToolInvoke] 收到请求: request_id=%s tool=%s caller_ip=%s", + request_id, + tool_name, + caller_ip, + ) + + if use_callback: + # 异步执行 + 回调 + asyncio.create_task( + self._execute_and_callback( + request_id=request_id, + tool_name=tool_name, + args=args, + body_context=body.get("context"), + callback_url=callback_url, + callback_headers=callback_headers, + timeout=cfg.api.tool_invoke_timeout, + callback_timeout=cfg.api.tool_invoke_callback_timeout, + ) + ) + return web.json_response( + { + "ok": True, + "request_id": request_id, + "tool_name": tool_name, + "status": "accepted", + } + ) + + # 同步执行 + result = await self._execute_tool_invoke( + request_id=request_id, + tool_name=tool_name, + args=args, + body_context=body.get("context"), + timeout=cfg.api.tool_invoke_timeout, + ) + return web.json_response(result) + + async def _execute_tool_invoke( + self, + *, + request_id: str, + tool_name: str, + args: dict[str, Any], + body_context: Any, + timeout: int, + ) -> dict[str, Any]: + """执行工具调用并返回结果字典。""" + ai = self._ctx.ai + if ai is None: + return { + "ok": False, + "request_id": request_id, + "tool_name": tool_name, + "error": "AI client not ready", + "duration_ms": 0, + } + + # 解析请求上下文 + ctx_data: dict[str, Any] = {} + if isinstance(body_context, dict): + ctx_data = body_context + request_type = str(ctx_data.get("request_type", "api") or "api") + group_id = ctx_data.get("group_id") + user_id = ctx_data.get("user_id") + sender_id = ctx_data.get("sender_id") + + args_keys = list(args.keys()) + logger.info( + "[ToolInvoke] 开始执行: request_id=%s tool=%s args_keys=%s", + request_id, + tool_name, + args_keys, + ) + + start = time.perf_counter() + try: + async with RequestContext( + request_type=request_type, + group_id=int(group_id) if group_id is not None else None, + user_id=int(user_id) if user_id is not None else None, + sender_id=int(sender_id) if sender_id is not None else None, + ) as ctx: + # 注入核心服务资源 + if self._ctx.sender is not None: + ctx.set_resource("sender", self._ctx.sender) + if self._ctx.history_manager is not None: + ctx.set_resource("history_manager", self._ctx.history_manager) + runtime_config = getattr(ai, "runtime_config", None) + if runtime_config is not None: + ctx.set_resource("runtime_config", runtime_config) + memory_storage = getattr(ai, "memory_storage", None) + if memory_storage is not None: + ctx.set_resource("memory_storage", memory_storage) + if self._ctx.onebot is not None: + ctx.set_resource("onebot_client", self._ctx.onebot) + if self._ctx.scheduler is not None: + ctx.set_resource("scheduler", self._ctx.scheduler) + if self._ctx.cognitive_service is not None: + ctx.set_resource("cognitive_service", self._ctx.cognitive_service) + + tool_context: dict[str, Any] = { + "request_type": request_type, + "request_id": request_id, + } + if group_id is not None: + tool_context["group_id"] = int(group_id) + if user_id is not None: + tool_context["user_id"] = int(user_id) + if sender_id is not None: + tool_context["sender_id"] = int(sender_id) + + tool_manager = getattr(ai, "tool_manager", None) + if tool_manager is None: + raise RuntimeError("ToolManager not available") + + raw_result = await asyncio.wait_for( + tool_manager.execute_tool(tool_name, args, tool_context), + timeout=timeout, + ) + + elapsed_ms = round((time.perf_counter() - start) * 1000, 1) + result_str = str(raw_result or "") + logger.info( + "[ToolInvoke] 执行完成: request_id=%s tool=%s ok=true " + "duration_ms=%s result_len=%d", + request_id, + tool_name, + elapsed_ms, + len(result_str), + ) + return { + "ok": True, + "request_id": request_id, + "tool_name": tool_name, + "result": result_str, + "duration_ms": elapsed_ms, + } + + except asyncio.TimeoutError: + elapsed_ms = round((time.perf_counter() - start) * 1000, 1) + logger.warning( + "[ToolInvoke] 执行超时: request_id=%s tool=%s timeout=%ds", + request_id, + tool_name, + timeout, + ) + return { + "ok": False, + "request_id": request_id, + "tool_name": tool_name, + "error": f"Execution timed out after {timeout}s", + "duration_ms": elapsed_ms, + } + except Exception as exc: + elapsed_ms = round((time.perf_counter() - start) * 1000, 1) + logger.exception( + "[ToolInvoke] 执行失败: request_id=%s tool=%s error=%s", + request_id, + tool_name, + exc, + ) + return { + "ok": False, + "request_id": request_id, + "tool_name": tool_name, + "error": str(exc), + "duration_ms": elapsed_ms, + } + + async def _execute_and_callback( + self, + *, + request_id: str, + tool_name: str, + args: dict[str, Any], + body_context: Any, + callback_url: str, + callback_headers: dict[str, str], + timeout: int, + callback_timeout: int, + ) -> None: + """异步执行工具并发送回调。""" + result = await self._execute_tool_invoke( + request_id=request_id, + tool_name=tool_name, + args=args, + body_context=body_context, + timeout=timeout, + ) + + payload = { + "request_id": result["request_id"], + "tool_name": result["tool_name"], + "ok": result["ok"], + "result": result.get("result"), + "duration_ms": result.get("duration_ms", 0), + "error": result.get("error"), + } + + try: + cb_timeout = ClientTimeout(total=callback_timeout) + async with ClientSession(timeout=cb_timeout) as session: + headers = {"Content-Type": "application/json"} + headers.update(callback_headers) + async with session.post( + callback_url, + json=payload, + headers=headers, + ) as resp: + logger.info( + "[ToolInvoke] 回调发送: request_id=%s url=%s status=%d", + request_id, + _mask_url(callback_url), + resp.status, + ) + except Exception as exc: + logger.warning( + "[ToolInvoke] 回调失败: request_id=%s url=%s error=%s", + request_id, + _mask_url(callback_url), + exc, + ) diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 31bc2e56..cd8559d1 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -2428,12 +2428,36 @@ def _parse_api_config(data: dict[str, Any]) -> APIConfig: openapi_enabled = _coerce_bool(section.get("openapi_enabled"), True) + tool_invoke_enabled = _coerce_bool(section.get("tool_invoke_enabled"), False) + tool_invoke_expose = _coerce_str( + section.get("tool_invoke_expose"), "tools+toolsets" + ) + valid_expose = {"tools", "toolsets", "tools+toolsets", "agents", "all"} + if tool_invoke_expose not in valid_expose: + tool_invoke_expose = "tools+toolsets" + tool_invoke_allowlist = _coerce_str_list(section.get("tool_invoke_allowlist")) + tool_invoke_denylist = _coerce_str_list(section.get("tool_invoke_denylist")) + tool_invoke_timeout = _coerce_int(section.get("tool_invoke_timeout"), 120) + if tool_invoke_timeout <= 0: + tool_invoke_timeout = 120 + tool_invoke_callback_timeout = _coerce_int( + section.get("tool_invoke_callback_timeout"), 10 + ) + if tool_invoke_callback_timeout <= 0: + tool_invoke_callback_timeout = 10 + return APIConfig( enabled=enabled, host=host, port=port, auth_key=auth_key, openapi_enabled=openapi_enabled, + tool_invoke_enabled=tool_invoke_enabled, + tool_invoke_expose=tool_invoke_expose, + tool_invoke_allowlist=tool_invoke_allowlist, + tool_invoke_denylist=tool_invoke_denylist, + tool_invoke_timeout=tool_invoke_timeout, + tool_invoke_callback_timeout=tool_invoke_callback_timeout, ) @staticmethod diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index 720cfa61..474b394c 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -221,3 +221,9 @@ class APIConfig: port: int = 8788 auth_key: str = "changeme" openapi_enabled: bool = True + tool_invoke_enabled: bool = False + tool_invoke_expose: str = "tools+toolsets" + tool_invoke_allowlist: list[str] = field(default_factory=list) + tool_invoke_denylist: list[str] = field(default_factory=list) + tool_invoke_timeout: int = 120 + tool_invoke_callback_timeout: int = 10 diff --git a/src/Undefined/webui/routes/_runtime.py b/src/Undefined/webui/routes/_runtime.py index 77e4a4f0..c6bb39f5 100644 --- a/src/Undefined/webui/routes/_runtime.py +++ b/src/Undefined/webui/routes/_runtime.py @@ -382,3 +382,39 @@ async def runtime_chat_file_handler(request: web.Request) -> web.StreamResponse: path=target, headers={"Content-Disposition": disposition}, ) + + +# ------------------------------------------------------------------ +# Tool Invoke API proxy +# ------------------------------------------------------------------ + +_TOOL_INVOKE_PROXY_TIMEOUT_SECONDS = 180.0 + + +@routes.get("/api/v1/management/runtime/tools") +@routes.get("/api/runtime/tools") +async def runtime_tools_list_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/tools", + ) + + +@routes.post("/api/v1/management/runtime/tools/invoke") +@routes.post("/api/runtime/tools/invoke") +async def runtime_tools_invoke_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + return await _proxy_runtime( + method="POST", + path="/api/v1/tools/invoke", + payload=body, + timeout_seconds=_TOOL_INVOKE_PROXY_TIMEOUT_SECONDS, + ) diff --git a/tests/test_config_api.py b/tests/test_config_api.py index e2c068a0..936a94a3 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -17,6 +17,12 @@ def test_api_config_defaults_when_missing(tmp_path: Path) -> None: assert cfg.api.port == 8788 assert cfg.api.auth_key == "changeme" assert cfg.api.openapi_enabled is True + assert cfg.api.tool_invoke_enabled is False + assert cfg.api.tool_invoke_expose == "tools+toolsets" + assert cfg.api.tool_invoke_allowlist == [] + assert cfg.api.tool_invoke_denylist == [] + assert cfg.api.tool_invoke_timeout == 120 + assert cfg.api.tool_invoke_callback_timeout == 10 def test_api_config_custom_values(tmp_path: Path) -> None: @@ -49,3 +55,48 @@ def test_api_config_invalid_values_fallback(tmp_path: Path) -> None: ) assert cfg.api.port == 8788 assert cfg.api.auth_key == "changeme" + + +def test_api_tool_invoke_config_custom(tmp_path: Path) -> None: + cfg = _load_config( + tmp_path / "config.toml", + """ +[api] +tool_invoke_enabled = true +tool_invoke_expose = "all" +tool_invoke_allowlist = ["get_current_time", "end"] +tool_invoke_denylist = ["python_interpreter"] +tool_invoke_timeout = 60 +tool_invoke_callback_timeout = 5 +""", + ) + assert cfg.api.tool_invoke_enabled is True + assert cfg.api.tool_invoke_expose == "all" + assert cfg.api.tool_invoke_allowlist == ["get_current_time", "end"] + assert cfg.api.tool_invoke_denylist == ["python_interpreter"] + assert cfg.api.tool_invoke_timeout == 60 + assert cfg.api.tool_invoke_callback_timeout == 5 + + +def test_api_tool_invoke_invalid_expose_fallback(tmp_path: Path) -> None: + cfg = _load_config( + tmp_path / "config.toml", + """ +[api] +tool_invoke_expose = "invalid_value" +""", + ) + assert cfg.api.tool_invoke_expose == "tools+toolsets" + + +def test_api_tool_invoke_invalid_timeout_fallback(tmp_path: Path) -> None: + cfg = _load_config( + tmp_path / "config.toml", + """ +[api] +tool_invoke_timeout = -1 +tool_invoke_callback_timeout = 0 +""", + ) + assert cfg.api.tool_invoke_timeout == 120 + assert cfg.api.tool_invoke_callback_timeout == 10 diff --git a/tests/test_runtime_api_tool_invoke.py b/tests/test_runtime_api_tool_invoke.py new file mode 100644 index 00000000..b0134fb3 --- /dev/null +++ b/tests/test_runtime_api_tool_invoke.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from aiohttp import web +from aiohttp.web_response import Response + +from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api.app import _validate_callback_url + + +def _json(response: Response) -> Any: + text = response.text + assert text is not None + return json.loads(text) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_tool_schema(name: str, description: str = "") -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": name, + "description": description or f"Tool {name}", + "parameters": {"type": "object", "properties": {}}, + }, + } + + +class _FakeToolRegistry: + def get_tools_schema(self) -> list[dict[str, Any]]: + return [ + _make_tool_schema("get_current_time"), + _make_tool_schema("end"), + _make_tool_schema("messages.send_message"), + _make_tool_schema("scheduler.create_schedule_task"), + _make_tool_schema("mcp.server.tool"), + ] + + +class _FakeAgentRegistry: + def get_agents_schema(self) -> list[dict[str, Any]]: + return [ + _make_tool_schema("web_agent"), + _make_tool_schema("code_agent"), + ] + + +class _FakeToolManager: + async def execute_tool( + self, name: str, args: dict[str, Any], context: dict[str, Any] + ) -> str: + return f"executed:{name}" + + +def _make_api_cfg( + *, + tool_invoke_enabled: bool = True, + tool_invoke_expose: str = "tools+toolsets", + tool_invoke_allowlist: list[str] | None = None, + tool_invoke_denylist: list[str] | None = None, + tool_invoke_timeout: int = 120, + tool_invoke_callback_timeout: int = 10, +) -> SimpleNamespace: + return SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="testkey", + openapi_enabled=True, + tool_invoke_enabled=tool_invoke_enabled, + tool_invoke_expose=tool_invoke_expose, + tool_invoke_allowlist=tool_invoke_allowlist or [], + tool_invoke_denylist=tool_invoke_denylist or [], + tool_invoke_timeout=tool_invoke_timeout, + tool_invoke_callback_timeout=tool_invoke_callback_timeout, + ) + + +def _make_server( + api_cfg: SimpleNamespace | None = None, +) -> RuntimeAPIServer: + if api_cfg is None: + api_cfg = _make_api_cfg() + + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace(api=api_cfg), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + memory_storage=None, + tool_registry=_FakeToolRegistry(), + agent_registry=_FakeAgentRegistry(), + tool_manager=_FakeToolManager(), + runtime_config=SimpleNamespace(), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(), + sender=SimpleNamespace(), + ) + return RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + +def _make_request( + query: dict[str, str] | None = None, + json_body: dict[str, Any] | None = None, + remote: str = "127.0.0.1", +) -> web.Request: + ns = SimpleNamespace( + query=query or {}, + remote=remote, + ) + if json_body is not None: + + async def _json() -> dict[str, Any]: + return json_body + + ns.json = _json + return cast(web.Request, cast(Any, ns)) + + +# --------------------------------------------------------------------------- +# _validate_callback_url tests +# --------------------------------------------------------------------------- + + +def test_callback_url_allows_http() -> None: + assert _validate_callback_url("http://example.com/hook") is None + assert _validate_callback_url("http://localhost:8000/hook") is None + assert _validate_callback_url("http://127.0.0.1:9000/hook") is None + assert _validate_callback_url("http://192.168.1.1/hook") is None + + +def test_callback_url_allows_https() -> None: + assert _validate_callback_url("https://example.com/hook") is None + + +def test_callback_url_rejects_bad_scheme() -> None: + err = _validate_callback_url("ftp://example.com/file") + assert err is not None + assert "http" in err + + +# --------------------------------------------------------------------------- +# _get_filtered_tools tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_tools_list_disabled_returns_403() -> None: + server = _make_server(_make_api_cfg(tool_invoke_enabled=False)) + request = _make_request() + response = await server._tools_list_handler(request) + assert response.status == 403 + + +@pytest.mark.asyncio +async def test_tools_list_expose_tools_only() -> None: + server = _make_server(_make_api_cfg(tool_invoke_expose="tools")) + response = await server._tools_list_handler(_make_request()) + payload = _json(response) + names = {t["function"]["name"] for t in payload["tools"]} + # 基础工具:get_current_time, end(无点号且非 agent) + assert "get_current_time" in names + assert "end" in names + # 工具集和 agent 不应出现 + assert "messages.send_message" not in names + assert "web_agent" not in names + assert "mcp.server.tool" not in names + + +@pytest.mark.asyncio +async def test_tools_list_expose_toolsets_only() -> None: + server = _make_server(_make_api_cfg(tool_invoke_expose="toolsets")) + response = await server._tools_list_handler(_make_request()) + payload = _json(response) + names = {t["function"]["name"] for t in payload["tools"]} + assert "messages.send_message" in names + assert "scheduler.create_schedule_task" in names + assert "get_current_time" not in names + assert "web_agent" not in names + assert "mcp.server.tool" not in names + + +@pytest.mark.asyncio +async def test_tools_list_expose_tools_plus_toolsets() -> None: + server = _make_server(_make_api_cfg(tool_invoke_expose="tools+toolsets")) + response = await server._tools_list_handler(_make_request()) + payload = _json(response) + names = {t["function"]["name"] for t in payload["tools"]} + assert "get_current_time" in names + assert "messages.send_message" in names + assert "web_agent" not in names + assert "mcp.server.tool" not in names + + +@pytest.mark.asyncio +async def test_tools_list_expose_agents_only() -> None: + server = _make_server(_make_api_cfg(tool_invoke_expose="agents")) + response = await server._tools_list_handler(_make_request()) + payload = _json(response) + names = {t["function"]["name"] for t in payload["tools"]} + assert "web_agent" in names + assert "code_agent" in names + assert "get_current_time" not in names + + +@pytest.mark.asyncio +async def test_tools_list_expose_all() -> None: + server = _make_server(_make_api_cfg(tool_invoke_expose="all")) + response = await server._tools_list_handler(_make_request()) + payload = _json(response) + names = {t["function"]["name"] for t in payload["tools"]} + assert "get_current_time" in names + assert "messages.send_message" in names + assert "web_agent" in names + assert "mcp.server.tool" in names + + +@pytest.mark.asyncio +async def test_tools_list_denylist_filters() -> None: + server = _make_server( + _make_api_cfg( + tool_invoke_expose="all", + tool_invoke_denylist=["web_agent", "end"], + ) + ) + response = await server._tools_list_handler(_make_request()) + payload = _json(response) + names = {t["function"]["name"] for t in payload["tools"]} + assert "web_agent" not in names + assert "end" not in names + assert "get_current_time" in names + + +@pytest.mark.asyncio +async def test_tools_list_allowlist_overrides_expose() -> None: + server = _make_server( + _make_api_cfg( + tool_invoke_expose="tools", + tool_invoke_allowlist=["web_agent", "get_current_time"], + ) + ) + response = await server._tools_list_handler(_make_request()) + payload = _json(response) + names = {t["function"]["name"] for t in payload["tools"]} + # allowlist 覆盖 expose,所以 agent 也能出现 + assert names == {"web_agent", "get_current_time"} + + +@pytest.mark.asyncio +async def test_tools_list_denylist_overrides_allowlist() -> None: + server = _make_server( + _make_api_cfg( + tool_invoke_allowlist=["get_current_time", "end"], + tool_invoke_denylist=["end"], + ) + ) + response = await server._tools_list_handler(_make_request()) + payload = _json(response) + names = {t["function"]["name"] for t in payload["tools"]} + assert "end" not in names + assert "get_current_time" in names + + +# --------------------------------------------------------------------------- +# _tools_invoke_handler tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_invoke_disabled_returns_403() -> None: + server = _make_server(_make_api_cfg(tool_invoke_enabled=False)) + request = _make_request( + json_body={ + "tool_name": "get_current_time", + "args": {}, + } + ) + response = await server._tools_invoke_handler(request) + assert response.status == 403 + + +@pytest.mark.asyncio +async def test_invoke_missing_tool_name() -> None: + server = _make_server() + request = _make_request(json_body={"args": {}}) + response = await server._tools_invoke_handler(request) + assert response.status == 400 + payload = _json(response) + assert "tool_name" in payload["error"] + + +@pytest.mark.asyncio +async def test_invoke_args_not_dict() -> None: + server = _make_server() + request = _make_request( + json_body={ + "tool_name": "get_current_time", + "args": "not_a_dict", + } + ) + response = await server._tools_invoke_handler(request) + assert response.status == 400 + payload = _json(response) + assert "args" in payload["error"] + + +@pytest.mark.asyncio +async def test_invoke_tool_not_available() -> None: + server = _make_server() + request = _make_request( + json_body={ + "tool_name": "nonexistent_tool", + "args": {}, + } + ) + response = await server._tools_invoke_handler(request) + assert response.status == 404 + + +@pytest.mark.asyncio +async def test_invoke_sync_success() -> None: + server = _make_server() + request = _make_request( + json_body={ + "tool_name": "get_current_time", + "args": {"format": "iso"}, + } + ) + response = await server._tools_invoke_handler(request) + assert response.status == 200 + payload = _json(response) + assert payload["ok"] is True + assert payload["tool_name"] == "get_current_time" + assert payload["result"] == "executed:get_current_time" + assert "request_id" in payload + assert "duration_ms" in payload + + +@pytest.mark.asyncio +async def test_invoke_with_context() -> None: + server = _make_server() + request = _make_request( + json_body={ + "tool_name": "get_current_time", + "args": {}, + "context": { + "request_type": "group", + "group_id": 12345, + "user_id": 67890, + "sender_id": 67890, + }, + } + ) + response = await server._tools_invoke_handler(request) + assert response.status == 200 + payload = _json(response) + assert payload["ok"] is True + + +@pytest.mark.asyncio +async def test_invoke_callback_bad_url_rejected() -> None: + server = _make_server() + request = _make_request( + json_body={ + "tool_name": "get_current_time", + "args": {}, + "callback": { + "enabled": True, + "url": "ftp://evil.example.com/hook", + }, + } + ) + response = await server._tools_invoke_handler(request) + assert response.status == 400 + payload = _json(response) + assert "http" in payload["error"] + + +@pytest.mark.asyncio +async def test_invoke_callback_missing_url_rejected() -> None: + server = _make_server() + request = _make_request( + json_body={ + "tool_name": "get_current_time", + "args": {}, + "callback": {"enabled": True}, + } + ) + response = await server._tools_invoke_handler(request) + assert response.status == 400 + payload = _json(response) + assert "url" in payload["error"].lower() + + +@pytest.mark.asyncio +async def test_invoke_callback_accepted() -> None: + server = _make_server() + request = _make_request( + json_body={ + "tool_name": "get_current_time", + "args": {}, + "callback": { + "enabled": True, + "url": "https://webhook.example.com/hook", + "headers": {"X-Secret": "abc"}, + }, + } + ) + response = await server._tools_invoke_handler(request) + assert response.status == 200 + payload = _json(response) + assert payload["ok"] is True + assert payload["status"] == "accepted" + assert "request_id" in payload + + +# --------------------------------------------------------------------------- +# OpenAPI spec includes tool invoke paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_openapi_spec_includes_tool_invoke_paths() -> None: + server = _make_server() + request = _make_request() + response = await server._openapi_handler(request) + spec = _json(response) + assert "/api/v1/tools" in spec["paths"] + assert "/api/v1/tools/invoke" in spec["paths"] From 2e6ea49101085038da22a81a10b70ecf47a3418c Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 13 Mar 2026 21:41:06 +0800 Subject: [PATCH 02/10] fix: unreachable dead code in `_validate_callback_url` Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/Undefined/api/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 62f0b8dd..7ddd0dcc 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -249,7 +249,6 @@ def _validate_callback_url(url: str) -> str | None: return None - return None async def _probe_http_endpoint( From 070ab8c47ed0a3c7d96b4bacf6592b6972df44ad Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 13 Mar 2026 22:21:37 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(api):=20Tool=20Invoke=20API=20?= =?UTF-8?q?=E5=A4=9A=E9=A1=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存 fire-and-forget task 引用到 _background_tasks 集合,防止被 GC - 合并 get_agents_schema() 调用,避免非 all 模式下重复调用 - WebUI 代理超时改为动态读取 tool_invoke_timeout + 60s 缓冲 - 回调移除多余 Content-Type header,由 aiohttp json= 自动处理 - 文档修正 callback URL 要求为支持 HTTP 和 HTTPS - RequestContext 类型注释补充 "api" 类型 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/openapi.md | 2 +- src/Undefined/api/app.py | 30 ++++++++++++++------------ src/Undefined/context.py | 2 +- src/Undefined/webui/routes/_runtime.py | 7 +++--- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/openapi.md b/docs/openapi.md index f509e8fe..e1891cb8 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -280,7 +280,7 @@ curl http://127.0.0.1:8788/openapi.json | 子字段 | 类型 | 必填 | 说明 | |--------|------|------|------| | `enabled` | `bool` | 是 | 是否启用回调 | -| `url` | `string` | 是(当 enabled=true) | webhook URL(须 HTTPS,回环地址可 HTTP) | +| `url` | `string` | 是(当 enabled=true) | webhook URL(支持 HTTP 和 HTTPS) | | `headers` | `object` | 否 | 自定义回调请求头(键值对) | **同步响应**(无回调): diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 7ddd0dcc..bb6d1052 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -250,7 +250,6 @@ def _validate_callback_url(url: str) -> str | None: return None - async def _probe_http_endpoint( *, name: str, @@ -463,6 +462,7 @@ def __init__( self._port = port self._runner: web.AppRunner | None = None self._site: web.TCPSite | None = None + self._background_tasks: set[asyncio.Task[Any]] = set() async def start(self) -> None: app = self._create_app() @@ -1111,8 +1111,17 @@ def _get_filtered_tools(self) -> list[dict[str, Any]]: all_schemas: list[dict[str, Any]] = [] if tool_reg is not None: all_schemas.extend(tool_reg.get_tools_schema()) + + # 收集 agent schema 并缓存名称集合(避免重复调用) + agent_names: set[str] = set() if agent_reg is not None: - all_schemas.extend(agent_reg.get_agents_schema()) + agent_schemas = agent_reg.get_agents_schema() + all_schemas.extend(agent_schemas) + for schema in agent_schemas: + func = schema.get("function", {}) + name = str(func.get("name", "")) + if name: + agent_names.add(name) denylist: set[str] = set(api_cfg.tool_invoke_denylist) allowlist: set[str] = set(api_cfg.tool_invoke_allowlist) @@ -1134,14 +1143,6 @@ def _get_name(schema: dict[str, Any]) -> str: if expose == "all": return all_schemas - # 收集 agent 名称集合 - agent_names: set[str] = set() - if agent_reg is not None: - for schema in agent_reg.get_agents_schema(): - name = _get_name(schema) - if name: - agent_names.add(name) - def _is_tool(name: str) -> bool: return "." not in name and name not in agent_names @@ -1243,7 +1244,7 @@ async def _tools_invoke_handler(self, request: web.Request) -> Response: if use_callback: # 异步执行 + 回调 - asyncio.create_task( + task = asyncio.create_task( self._execute_and_callback( request_id=request_id, tool_name=tool_name, @@ -1255,6 +1256,8 @@ async def _tools_invoke_handler(self, request: web.Request) -> Response: callback_timeout=cfg.api.tool_invoke_callback_timeout, ) ) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) return web.json_response( { "ok": True, @@ -1439,12 +1442,11 @@ async def _execute_and_callback( try: cb_timeout = ClientTimeout(total=callback_timeout) async with ClientSession(timeout=cb_timeout) as session: - headers = {"Content-Type": "application/json"} - headers.update(callback_headers) + # aiohttp json= 自动设置 Content-Type,无需手动指定 async with session.post( callback_url, json=payload, - headers=headers, + headers=callback_headers or None, ) as resp: logger.info( "[ToolInvoke] 回调发送: request_id=%s url=%s status=%d", diff --git a/src/Undefined/context.py b/src/Undefined/context.py index 4d00dc56..4cd171c3 100644 --- a/src/Undefined/context.py +++ b/src/Undefined/context.py @@ -34,7 +34,7 @@ class RequestContext: def __init__( self, - request_type: str, # "group" | "private" + request_type: str, # "group" | "private" | "api" group_id: Optional[int] = None, user_id: Optional[int] = None, sender_id: Optional[int] = None, diff --git a/src/Undefined/webui/routes/_runtime.py b/src/Undefined/webui/routes/_runtime.py index c6bb39f5..9cfc0df8 100644 --- a/src/Undefined/webui/routes/_runtime.py +++ b/src/Undefined/webui/routes/_runtime.py @@ -388,8 +388,6 @@ async def runtime_chat_file_handler(request: web.Request) -> web.StreamResponse: # Tool Invoke API proxy # ------------------------------------------------------------------ -_TOOL_INVOKE_PROXY_TIMEOUT_SECONDS = 180.0 - @routes.get("/api/v1/management/runtime/tools") @routes.get("/api/runtime/tools") @@ -412,9 +410,12 @@ async def runtime_tools_invoke_handler(request: web.Request) -> Response: except Exception: return web.json_response({"error": "Invalid JSON"}, status=400) + cfg = get_config(strict=False) + # 代理超时 = 工具调用超时 + 60s 缓冲(覆盖网络开销) + proxy_timeout = float(cfg.api.tool_invoke_timeout) + 60.0 return await _proxy_runtime( method="POST", path="/api/v1/tools/invoke", payload=body, - timeout_seconds=_TOOL_INVOKE_PROXY_TIMEOUT_SECONDS, + timeout_seconds=proxy_timeout, ) From a51b6064a3c231b33dbb9454e67ce6dca54fc9ec Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 10:00:31 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat(naga):=20Naga=20Scoped=20Token=20?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E7=B3=BB=E7=BB=9F=E4=B8=8E=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 NagaAgent 双层鉴权(共享 api_key + scoped token)回调机制: - NagaStore 数据层:绑定/审批/吊销/校验,异步 JSON 持久化(文件锁 + 原子写入) - /naga 斜杠命令:bind/approve/reject/revoke/list/pending/info 七个子命令 - Callback API:POST /api/v1/naga/callback + GET /api/v1/naga/targets - scopes.json 子命令权限控制:支持 group_only/private_only/admin_only/superadmin_only 别名 - /help 权限可见性过滤:按用户权限级别隐藏不可用命令 - 总开关统一为 features.nagaagent_mode_enabled - 文档:configuration.md、slash-commands.md、openapi.md 同步更新 Co-Authored-By: Claude Opus 4.6 --- config.toml.example | 17 + docs/configuration.md | 27 + docs/openapi.md | 112 +++++ docs/slash-commands.md | 124 ++++- src/Undefined/api/app.py | 227 ++++++++- src/Undefined/api/naga_store.py | 245 +++++++++ src/Undefined/config/loader.py | 20 + src/Undefined/config/models.py | 14 + src/Undefined/main.py | 11 + src/Undefined/services/command.py | 1 + src/Undefined/skills/commands/help/handler.py | 22 + .../skills/commands/naga/config.json | 10 + src/Undefined/skills/commands/naga/handler.py | 465 ++++++++++++++++++ .../skills/commands/naga/scopes.json | 9 + tests/test_naga_store.py | 195 ++++++++ 15 files changed, 1495 insertions(+), 4 deletions(-) create mode 100644 src/Undefined/api/naga_store.py create mode 100644 src/Undefined/skills/commands/naga/config.json create mode 100644 src/Undefined/skills/commands/naga/handler.py create mode 100644 src/Undefined/skills/commands/naga/scopes.json create mode 100644 tests/test_naga_store.py diff --git a/config.toml.example b/config.toml.example index 6550dfac..8c3e706f 100644 --- a/config.toml.example +++ b/config.toml.example @@ -855,3 +855,20 @@ failed_cleanup_interval = 100 # zh: 单个任务最大自动重试次数(超过后移入 failed,0=不重试)。 # en: Max auto-retries per job before moving to failed (0=no retry). job_max_retries = 3 + +# zh: Naga 集成配置。总开关为 [features].nagaagent_mode_enabled, +# zh: 启用后允许 NagaAgent 通过绑定审批机制向 QQ 群/用户发送回调消息。 +# zh: ⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。 +# en: Naga integration settings. Master switch is [features].nagaagent_mode_enabled. +# en: When enabled, NagaAgent can send callback messages to QQ groups/users through a binding approval mechanism. +# en: ⚠️ This feature is for advanced NagaAgent integration scenarios. Not recommended for regular users. +[naga] +# zh: Naga 服务器 API 地址。 +# en: Naga server API URL. +api_url = "" +# zh: 双方共享密钥(Undefined ↔ Naga 身份验证)。 +# en: Shared secret key for authentication between Undefined and Naga. +api_key = "" +# zh: Naga 服务群聊名单:绑定/回调群发仅限这些群。 +# en: Allowed groups for Naga binding and group callback delivery. +allowed_groups = [] diff --git a/docs/configuration.md b/docs/configuration.md index e4244050..9cc94752 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -635,6 +635,32 @@ model_name = "gpt-4o-mini" --- +### 4.25 `[naga]` Naga 集成 + +> **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** + +启用后允许 NagaAgent 通过绑定审批机制向 QQ 群/用户发送回调消息。鉴权采用双层模型:共享密钥 `api_key` 验证服务器身份 + 每个绑定独立的 scoped token 验证调用权限。 + +**总开关**:`[features].nagaagent_mode_enabled = true`。Naga 集成所有功能(`/naga` 命令、回调端点、绑定存储)均受此总开关控制。 + +| 字段 | 默认值 | 说明 | 约束/回退 | +|---|---:|---|---| +| `api_url` | `""` | Naga 服务器 API 地址 | 为空时 token 同步/删除操作跳过 | +| `api_key` | `""` | Undefined ↔ Naga 共享密钥 | 回调端点通过 `Authorization: Bearer` 校验 | +| `allowed_groups` | `[]` | Naga 服务群聊名单 | 绑定命令和回调群发仅限名单内的群 | + +**作用域规则**: +- `/naga bind` 仅在 `allowed_groups` 内的群可用 +- 回调群发仅发到绑定时的群(该群须仍在 `allowed_groups` 内) +- 回调私聊只需总开关开启,不受 `allowed_groups` 限制 +- `/api/v1/naga/*` 端点仅在 `enabled=true` 时注册 + +**数据存储**:绑定数据持久化在 `data/naga_bindings.json`,启动时自动 `chmod 600`。 + +`naga.*` 变更需要重启进程才能生效。 + +--- + ## 5. 热更新与重启边界 ### 5.1 热更新监听对象 @@ -655,6 +681,7 @@ model_name = "gpt-4o-mini" - `webui.port` - `webui.password` - `api.*`(`enabled/host/port/auth_key/openapi_enabled`) +- `naga.*`(`api_url/api_key/allowed_groups`) ### 5.3 明确“会执行热应用”的字段 - 模型发车间隔 / 模型名 / 模型池变更(队列间隔刷新) diff --git a/docs/openapi.md b/docs/openapi.md index e1891cb8..a46f359d 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -432,3 +432,115 @@ WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 Header - 回调请求失败(日志 `[ToolInvoke] 回调失败`): - 检查回调 URL 是否可达、证书是否有效。 - 可通过 `tool_invoke_callback_timeout` 调整超时。 + +## 8. Naga 回调 API + +Naga 集成端点仅在 `[naga].enabled = true` 时注册。这些端点**不走主 API Key 中间件**,使用独立的共享密钥鉴权。 + +### 配置 + +```toml +[naga] +enabled = false +api_url = "" +api_key = "" +allowed_groups = [] +``` + +### 鉴权模型(双层) + +| 层级 | 作用 | 传递方式 | +|------|------|---------| +| 共享密钥 `api_key` | 服务器身份验证 | `Authorization: Bearer {api_key}` | +| Scoped Token `udf_xxx` | 绑定级别验证(per naga_id) | body 中 `token` 字段或 `X-Naga-Token` header | + +### POST /api/v1/naga/callback — 消息回调 + +Naga 服务器调用此端点向绑定的 QQ 用户/群发送消息。 + +请求体: + +```json +{ + "naga_id": "alice", + "token": "udf_xxx", + "message": { + "format": "text", + "content": "hello" + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `naga_id` | 是 | 绑定标识 | +| `token` | 是 | scoped token(`udf_` 前缀) | +| `message.format` | 是 | `text` / `markdown` / `html` | +| `message.content` | 是 | 消息内容 | + +发送逻辑: +- 私聊发给绑定的 QQ 用户(总开关开即可) +- 群聊发到绑定时的群(该群须仍在 `allowed_groups` 内) +- `markdown` / `html` 格式会渲染为图片发送 + +响应: + +```json +{ + "ok": true, + "sent_private": true, + "sent_group": true +} +``` + +### GET /api/v1/naga/targets — 查询发送目标 + +查询某个 naga_id 绑定的 QQ 用户和可用群。 + +请求: + +```http +GET /api/v1/naga/targets?naga_id=alice +Authorization: Bearer {api_key} +X-Naga-Token: udf_xxx +``` + +响应: + +```json +{ + "naga_id": "alice", + "bound_qq": 123456, + "groups": [ + { "group_id": 789, "group_name": "测试群" } + ] +} +``` + +### 错误状态码 + +| 状态码 | 含义 | +|--------|------| +| `400` | 请求体格式错误(缺少必填字段、format 不合法等) | +| `401` | 共享密钥校验失败 | +| `403` | scoped token 不匹配、绑定已吊销、或群不在白名单 | +| `503` | Naga 集成未就绪 | + +### cURL 示例 + +```bash +NAGA_KEY="your_shared_key" +API="http://127.0.0.1:8788" + +# 消息回调 +curl -X POST \ + -H "Authorization: Bearer $NAGA_KEY" \ + -H "Content-Type: application/json" \ + -d '{"naga_id":"alice","token":"udf_xxx","message":{"format":"text","content":"hello"}}' \ + "$API/api/v1/naga/callback" + +# 查询目标 +curl -H "Authorization: Bearer $NAGA_KEY" \ + -H "X-Naga-Token: udf_xxx" \ + "$API/api/v1/naga/targets?naga_id=alice" +``` diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 73a08176..3e33385e 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -174,6 +174,46 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 /bugfix 111111 222222 2024/12/01/09:00 2024/12/01/18:00 ``` +#### 6. Naga 集成管理 + +> **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** 需要先在 `config.toml` 中启用 `[naga]` 配置。 + +- **/naga \<子命令\> [参数]** + - **说明**:NagaAgent 绑定管理。通过子命令完成绑定申请、审批、吊销和查询。 + - **前置条件**:`config.toml` 中 `features.nagaagent_mode_enabled = true`。 + + **子命令列表**: + + | 子命令 | 权限 | 作用域 | 说明 | + |--------|------|--------|------| + | `bind ` | 公开 | 仅群聊 | 在当前群提交绑定申请,记录 QQ 号和群号 | + | `approve ` | 超管 | 群聊/私聊 | 通过绑定申请,生成 scoped token 并同步到 Naga | + | `reject ` | 超管 | 群聊/私聊 | 拒绝绑定申请 | + | `revoke ` | 超管 | 群聊/私聊 | 吊销已有绑定并通知 Naga 删除 token | + | `list` | 超管 | 群聊/私聊 | 列出所有活跃绑定 | + | `pending` | 超管 | 群聊/私聊 | 列出待审核申请 | + | `info ` | 超管 | 群聊/私聊 | 查看绑定详情(token 脱敏显示) | + + **权限模型**:命令入口 `config.json` 声明 `"permission": "public"`(允许所有人触发),实际权限由 `scopes.json` 按子命令细粒度控制(详见下方"scopes.json 子命令权限"一节)。 + + - **示例**: + ``` + /naga bind alice ← 群内普通用户提交绑定 + /naga approve alice ← 超管通过(私聊或群聊均可) + /naga reject alice ← 超管拒绝 + /naga revoke alice ← 超管吊销 + /naga list ← 超管查看所有绑定 + /naga pending ← 超管查看待审核列表 + /naga info alice ← 超管查看详情 + ``` + + - **额外行为**: + - `bind` 仅在 `naga.allowed_groups` 白名单内的群可用,其余群静默忽略。 + - `approve` 成功后会自动调 Naga API 同步 token 并私聊通知申请人。 + - `reject` 成功后私聊通知申请人。 + - `revoke` 成功后调 Naga API 删除 token。 + - `bind` 提交后自动私聊通知超管。 + --- ## 🛠️ 第二部分:如何自定义/扩展新的斜杠命令? @@ -278,11 +318,89 @@ async def execute(args: list[str], context: CommandContext) -> None: 命令可以限定谁能执行: -- `"superadmin"`: 仅 `config.toml` 中 `[core].super_admins` 列表内的人可执行。 +- `"superadmin"`: 仅 `config.toml` 中 `[core].superadmin_qq` 的人可执行。 - `"admin"`: 超级管理员 + `config.local.json` 动态添加的管理员均可执行。 -- `"anyone"`: 群内或私聊中的任何用户均可执行。(注意风控和被滥用刷屏的风险) +- `"public"`: 群内或私聊中的任何用户均可执行。(注意风控和被滥用刷屏的风险) + +> **可见性**:`/help` 会根据当前用户的权限级别过滤命令列表。`superadmin` 权限的命令不会对普通用户显示;`admin` 权限的命令不会对非管理员显示。 + +### 4. `scopes.json` — 子命令权限控制 + +对于拥有多个子命令的复合命令(如 `/naga`),可以在命令目录下新建 `scopes.json` 文件,按子命令名声明独立的权限与作用域。 + +#### 基本格式 + +```json +{ + "bind": "group_only", + "approve": "superadmin", + "reject": "superadmin", + "list": "superadmin", + "info": "superadmin" +} +``` + +#### 可用的 scope 值 + +| 值 | 别名 | 含义 | +|----|------|------| +| `public` | — | 任何人、任何场景均可使用 | +| `admin` | `admin_only` | 仅管理员及超管可使用 | +| `superadmin` | `superadmin_only` | 仅超级管理员可使用 | +| `group_only` | — | 任何人均可使用,但仅限群聊场景 | +| `private_only` | — | 任何人均可使用,但仅限私聊场景 | + +**说明**: +- 未在 `scopes.json` 中列出的子命令默认视为 `superadmin`。 +- `scopes.json` 由命令 handler 自行加载和校验,注册表不直接读取。 +- 使用 `group_only`/`private_only` 时,scope 同时隐含 **权限为 public**(只限制场景,不限制身份)。 + +#### handler 中如何使用 + +在 handler 中加载 scopes.json 并调用检查函数: + +```python +import json +from pathlib import Path + +_SCOPES_FILE = Path(__file__).parent / "scopes.json" + +_SCOPE_ALIASES: dict[str, str] = { + "admin_only": "admin", + "superadmin_only": "superadmin", +} + +def _load_scopes() -> dict[str, str]: + try: + with open(_SCOPES_FILE, encoding="utf-8") as f: + data = json.load(f) + return {str(k): str(v) for k, v in data.items()} + except Exception: + return {} + +def _check_scope(subcmd: str, sender_id: int, context) -> str | None: + """返回错误提示或 None 表示通过。""" + scopes = _load_scopes() + raw = scopes.get(subcmd, "superadmin") + scope = _SCOPE_ALIASES.get(raw, raw) + + if scope == "group_only": + return "该子命令仅限群聊使用" if context.scope != "group" else None + if scope == "private_only": + return "该子命令仅限私聊使用" if context.scope != "private" else None + if scope == "public": + return None + if scope == "superadmin" and context.config.is_superadmin(sender_id): + return None + if scope == "admin" and ( + context.config.is_admin(sender_id) + or context.config.is_superadmin(sender_id) + ): + return None + return "权限不足" +``` -### 4. 自动注册与生效 +### 5. 自动注册与生效 你无需去任何主函数写 `import hello_world`! Undefined 会在运行时自动检测 `skills/commands/` 目录变化并热重载命令(新增目录、修改 `config.json` / `handler.py` / `README.md` 都会生效)。只需保证文件存在并且合法: diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index bb6d1052..8034480c 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -3,6 +3,7 @@ import asyncio import json import logging +import os import platform import socket import sys @@ -127,6 +128,7 @@ class RuntimeAPIContext: scheduler: Any = None cognitive_service: Any = None cognitive_job_queue: Any = None + naga_store: Any = None def _json_error(message: str, status: int = 400) -> Response: @@ -494,7 +496,9 @@ async def _auth_middleware( response = web.Response(status=204) _apply_cors_headers(request, response) return response - if request.path.startswith("/api/"): + if request.path.startswith("/api/") and not request.path.startswith( + "/api/v1/naga/" + ): expected = str(self._context.config_getter().api.auth_key or "") provided = request.headers.get(_AUTH_HEADER, "") if not expected or provided != expected: @@ -529,6 +533,16 @@ async def _auth_middleware( web.post("/api/v1/tools/invoke", self._tools_invoke_handler), ] ) + # Naga 端点仅在启用时注册(总开关为 features.nagaagent_mode_enabled) + cfg = self._context.config_getter() + if cfg.nagaagent_mode_enabled and self._context.naga_store is not None: + app.add_routes( + [ + web.post("/api/v1/naga/callback", self._naga_callback_handler), + web.get("/api/v1/naga/targets", self._naga_targets_handler), + ] + ) + logger.info("[RuntimeAPI] Naga 端点已注册") return app @property @@ -1461,3 +1475,214 @@ async def _execute_and_callback( _mask_url(callback_url), exc, ) + + # ------------------------------------------------------------------ + # Naga Callback / Targets API + # ------------------------------------------------------------------ + + def _verify_naga_api_key(self, request: web.Request) -> str | None: + """校验 Naga 共享密钥,返回错误信息或 None 表示通过。""" + cfg = self._ctx.config_getter() + expected = cfg.naga.api_key + if not expected: + return "naga api_key not configured" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return "missing or invalid Authorization header" + provided = auth_header[7:] + import secrets as _secrets + + if not _secrets.compare_digest(provided, expected): + return "invalid api_key" + return None + + async def _naga_callback_handler(self, request: web.Request) -> Response: + """POST /api/v1/naga/callback — Naga 消息回调""" + from Undefined.api.naga_store import mask_token + + # 1. 共享密钥校验 + auth_err = self._verify_naga_api_key(request) + if auth_err is not None: + logger.warning("[NagaCallback] 鉴权失败: %s", auth_err) + return _json_error("Unauthorized", status=401) + + # 2. 解析 body + try: + body = await request.json() + except Exception: + return _json_error("Invalid JSON", status=400) + + naga_id = str(body.get("naga_id", "") or "").strip() + token = str(body.get("token", "") or "").strip() + message = body.get("message") + + if not naga_id or not token: + return _json_error("naga_id and token are required", status=400) + if not isinstance(message, dict): + return _json_error("message object is required", status=400) + + fmt = str(message.get("format", "text") or "text").strip().lower() + content = str(message.get("content", "") or "").strip() + if not content: + return _json_error("message.content is required", status=400) + if fmt not in ("text", "markdown", "html"): + return _json_error( + "message.format must be 'text', 'markdown', or 'html'", status=400 + ) + + # 3. scoped token 校验 + naga_store = self._ctx.naga_store + if naga_store is None: + return _json_error("Naga integration not available", status=503) + + valid, err_msg = naga_store.verify(naga_id, token) + if not valid: + logger.warning( + "[NagaCallback] token 校验失败: naga_id=%s reason=%s token=%s", + naga_id, + err_msg, + mask_token(token), + ) + return _json_error(err_msg, status=403) + + # 4. 获取绑定信息 + binding = naga_store.get_binding(naga_id) + if binding is None: + return _json_error("binding not found", status=403) + + cfg = self._ctx.config_getter() + sender = self._ctx.sender + if sender is None: + return _json_error("sender not available", status=503) + + # 5. 按 format 渲染内容 + send_content: str | None = None + image_path: str | None = None + + if fmt == "text": + send_content = content + elif fmt in ("markdown", "html"): + import tempfile + + html_str = content + if fmt == "markdown": + html_str = await render_markdown_to_html(content) + fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="naga_cb_") + os.close(fd) + try: + await render_html_to_image(html_str, tmp_path) + image_path = tmp_path + except Exception as exc: + logger.warning("[NagaCallback] 渲染失败: %s", exc) + # 回退到文本发送 + send_content = content + + # 6. 发送消息 + sent_private = False + sent_group = False + + # 私聊发给绑定的 QQ 用户 + try: + if send_content is not None: + await sender.send_private_message(binding.qq_id, send_content) + elif image_path is not None: + cq_image = f"[CQ:image,file=file:///{image_path}]" + await sender.send_private_message(binding.qq_id, cq_image) + sent_private = True + except Exception as exc: + logger.warning( + "[NagaCallback] 私聊发送失败: naga_id=%s qq=%d error=%s", + naga_id, + binding.qq_id, + exc, + ) + + # 群聊发到绑定时的群(须仍在 allowed_groups 内) + if binding.group_id in cfg.naga.allowed_groups: + try: + if send_content is not None: + await sender.send_group_message(binding.group_id, send_content) + elif image_path is not None: + cq_image = f"[CQ:image,file=file:///{image_path}]" + await sender.send_group_message(binding.group_id, cq_image) + sent_group = True + except Exception as exc: + logger.warning( + "[NagaCallback] 群聊发送失败: naga_id=%s group=%d error=%s", + naga_id, + binding.group_id, + exc, + ) + else: + logger.info( + "[NagaCallback] 群 %d 不在 allowed_groups 中,跳过群发", + binding.group_id, + ) + + # 7. record_usage + await naga_store.record_usage(naga_id) + + return web.json_response( + { + "ok": True, + "sent_private": sent_private, + "sent_group": sent_group, + } + ) + + async def _naga_targets_handler(self, request: web.Request) -> Response: + """GET /api/v1/naga/targets — 查询发送目标""" + # 1. 共享密钥校验 + auth_err = self._verify_naga_api_key(request) + if auth_err is not None: + logger.warning("[NagaTargets] 鉴权失败: %s", auth_err) + return _json_error("Unauthorized", status=401) + + # 2. 参数获取 + naga_id = str(request.query.get("naga_id", "") or "").strip() + token = request.headers.get("X-Naga-Token", "") + + if not naga_id or not token: + return _json_error("naga_id and X-Naga-Token are required", status=400) + + # 3. scoped token 校验 + naga_store = self._ctx.naga_store + if naga_store is None: + return _json_error("Naga integration not available", status=503) + + valid, err_msg = naga_store.verify(naga_id, token) + if not valid: + return _json_error(err_msg, status=403) + + binding = naga_store.get_binding(naga_id) + if binding is None: + return _json_error("binding not found", status=403) + + cfg = self._ctx.config_getter() + + # 4. 构建可用群列表 + available_groups: list[dict[str, Any]] = [] + if binding.group_id in cfg.naga.allowed_groups: + group_info: dict[str, Any] = {"group_id": binding.group_id} + # 尝试获取群名 + onebot = self._ctx.onebot + if onebot is not None: + try: + info = await onebot.get_group_info(binding.group_id) + if isinstance(info, dict): + data = info.get("data", info) + if isinstance(data, dict): + name = data.get("group_name", "") + if name: + group_info["group_name"] = str(name) + except Exception: + pass + available_groups.append(group_info) + + return web.json_response( + { + "naga_id": naga_id, + "bound_qq": binding.qq_id, + "groups": available_groups, + } + ) diff --git a/src/Undefined/api/naga_store.py b/src/Undefined/api/naga_store.py new file mode 100644 index 00000000..b07e7ea1 --- /dev/null +++ b/src/Undefined/api/naga_store.py @@ -0,0 +1,245 @@ +"""Naga 绑定存储 — scoped token 管理""" + +from __future__ import annotations + +import asyncio +import logging +import os +import secrets +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from Undefined.utils.io import read_json, write_json + +logger = logging.getLogger(__name__) + +_TOKEN_PREFIX = "udf_" +_TOKEN_HEX_BYTES = 24 # 48 hex chars +_STORE_VERSION = 1 +_DATA_FILE = Path("data/naga_bindings.json") + + +@dataclass +class NagaBinding: + """已通过的 Naga 绑定""" + + naga_id: str + token: str + qq_id: int + group_id: int + created_at: float + revoked: bool = False + description: str = "" + last_used_at: float | None = None + use_count: int = 0 + + +@dataclass +class PendingBinding: + """待审核的绑定申请""" + + naga_id: str + qq_id: int + group_id: int + requested_at: float + + +def _generate_token() -> str: + return f"{_TOKEN_PREFIX}{secrets.token_hex(_TOKEN_HEX_BYTES)}" + + +def mask_token(token: str) -> str: + """日志脱敏:只显示前 12 字符 + '...'""" + if len(token) <= 12: + return token + return token[:12] + "..." + + +class NagaStore: + """Naga 绑定数据管理 + + 内存缓存 + JSON 文件持久化,所有读操作 O(1)。 + """ + + def __init__(self, data_file: Path = _DATA_FILE) -> None: + self._data_file = data_file + self._bindings: dict[str, NagaBinding] = {} + self._pending: dict[str, PendingBinding] = {} + self._lock = asyncio.Lock() + + async def load(self) -> None: + """从文件加载绑定数据""" + raw = await read_json(self._data_file, use_lock=True) + if raw is None: + logger.info("[NagaStore] 绑定文件不存在,使用空数据") + return + + if not isinstance(raw, dict): + logger.warning("[NagaStore] 绑定文件格式错误,使用空数据") + return + + bindings_raw = raw.get("bindings", {}) + if isinstance(bindings_raw, dict): + for naga_id, data in bindings_raw.items(): + if isinstance(data, dict): + self._bindings[naga_id] = NagaBinding( + naga_id=str(data.get("naga_id", naga_id)), + token=str(data.get("token", "")), + qq_id=int(data.get("qq_id", 0)), + group_id=int(data.get("group_id", 0)), + created_at=float(data.get("created_at", 0)), + revoked=bool(data.get("revoked", False)), + description=str(data.get("description", "")), + last_used_at=data.get("last_used_at"), + use_count=int(data.get("use_count", 0)), + ) + + pending_raw = raw.get("pending", {}) + if isinstance(pending_raw, dict): + for naga_id, data in pending_raw.items(): + if isinstance(data, dict): + self._pending[naga_id] = PendingBinding( + naga_id=str(data.get("naga_id", naga_id)), + qq_id=int(data.get("qq_id", 0)), + group_id=int(data.get("group_id", 0)), + requested_at=float(data.get("requested_at", 0)), + ) + + logger.info( + "[NagaStore] 加载完成: bindings=%d pending=%d", + len(self._bindings), + len(self._pending), + ) + + await asyncio.to_thread(self._restrict_permissions) + + def _restrict_permissions(self) -> None: + """限制数据文件权限(仅 Unix 生效)""" + if os.name != "posix": + return + try: + if self._data_file.exists(): + os.chmod(self._data_file, 0o600) + except OSError as exc: + logger.debug("[NagaStore] chmod 600 失败: %s", exc) + + async def save(self) -> None: + """持久化到文件""" + payload: dict[str, Any] = { + "version": _STORE_VERSION, + "bindings": {k: asdict(v) for k, v in self._bindings.items()}, + "pending": {k: asdict(v) for k, v in self._pending.items()}, + } + await write_json(self._data_file, payload) + await asyncio.to_thread(self._restrict_permissions) + + async def submit_binding( + self, naga_id: str, qq_id: int, group_id: int + ) -> tuple[bool, str]: + """提交绑定申请 + + Returns: + (success, message) + """ + async with self._lock: + if naga_id in self._bindings: + binding = self._bindings[naga_id] + if not binding.revoked: + return False, f"naga_id '{naga_id}' 已绑定" + if naga_id in self._pending: + return False, f"naga_id '{naga_id}' 已在审核队列中" + + self._pending[naga_id] = PendingBinding( + naga_id=naga_id, + qq_id=qq_id, + group_id=group_id, + requested_at=time.time(), + ) + await self.save() + return True, "申请已提交,等待超管审核" + + async def approve(self, naga_id: str) -> NagaBinding | None: + """审批通过:生成 token,移入 bindings""" + async with self._lock: + pending = self._pending.pop(naga_id, None) + if pending is None: + return None + + token = _generate_token() + binding = NagaBinding( + naga_id=naga_id, + token=token, + qq_id=pending.qq_id, + group_id=pending.group_id, + created_at=time.time(), + ) + self._bindings[naga_id] = binding + await self.save() + logger.info( + "[NagaStore] 绑定审批通过: naga_id=%s qq=%d group=%d token=%s", + naga_id, + binding.qq_id, + binding.group_id, + mask_token(token), + ) + return binding + + async def reject(self, naga_id: str) -> bool: + """拒绝绑定申请""" + async with self._lock: + if naga_id not in self._pending: + return False + del self._pending[naga_id] + await self.save() + logger.info("[NagaStore] 绑定申请已拒绝: naga_id=%s", naga_id) + return True + + async def revoke(self, naga_id: str) -> bool: + """吊销已有绑定""" + async with self._lock: + binding = self._bindings.get(naga_id) + if binding is None or binding.revoked: + return False + binding.revoked = True + await self.save() + logger.info("[NagaStore] 绑定已吊销: naga_id=%s", naga_id) + return True + + def list_bindings(self) -> list[NagaBinding]: + """列出所有活跃绑定""" + return [b for b in self._bindings.values() if not b.revoked] + + def list_pending(self) -> list[PendingBinding]: + """列出所有待审核申请""" + return list(self._pending.values()) + + def get_binding(self, naga_id: str) -> NagaBinding | None: + """按 naga_id 查询绑定""" + return self._bindings.get(naga_id) + + def verify(self, naga_id: str, token: str) -> tuple[bool, str]: + """校验 scoped token(纯内存操作) + + Returns: + (valid, error_message) + """ + binding = self._bindings.get(naga_id) + if binding is None: + return False, f"naga_id '{naga_id}' 未绑定" + if binding.revoked: + return False, f"naga_id '{naga_id}' 绑定已吊销" + if not secrets.compare_digest(binding.token, token): + return False, "token 不匹配" + return True, "" + + async def record_usage(self, naga_id: str) -> None: + """更新使用记录""" + async with self._lock: + binding = self._bindings.get(naga_id) + if binding is None: + return + binding.last_used_at = time.time() + binding.use_count += 1 + await self.save() diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index cd8559d1..f3b4b2e0 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -35,6 +35,7 @@ def load_dotenv( EmbeddingModelConfig, ModelPool, ModelPoolEntry, + NagaConfig, RerankModelConfig, SecurityModelConfig, VisionModelConfig, @@ -544,6 +545,8 @@ class Config: bilibili_auto_extract_private_ids: list[int] # 认知记忆 cognitive: CognitiveConfig + # Naga 集成 + naga: NagaConfig _allowed_group_ids_set: set[int] = dataclass_field( default_factory=set, init=False, @@ -1226,6 +1229,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi api_config = cls._parse_api_config(data) cognitive = cls._parse_cognitive_config(data) + naga = cls._parse_naga_config(data) if strict: cls._verify_required_fields( @@ -1357,6 +1361,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi knowledge_enable_rerank=knowledge_enable_rerank, knowledge_rerank_top_k=knowledge_rerank_top_k, cognitive=cognitive, + naga=naga, ) @property @@ -2460,6 +2465,21 @@ def _parse_api_config(data: dict[str, Any]) -> APIConfig: tool_invoke_callback_timeout=tool_invoke_callback_timeout, ) + @staticmethod + def _parse_naga_config(data: dict[str, Any]) -> NagaConfig: + section_raw = data.get("naga", {}) + section = section_raw if isinstance(section_raw, dict) else {} + + api_url = _coerce_str(section.get("api_url"), "") + api_key = _coerce_str(section.get("api_key"), "") + allowed_groups = _coerce_int_list(section.get("allowed_groups")) + + return NagaConfig( + api_url=api_url, + api_key=api_key, + allowed_groups=allowed_groups, + ) + @staticmethod def _parse_easter_egg_call_mode(value: Any) -> str: """解析彩蛋提示模式。 diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index 474b394c..b0c220d4 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -163,6 +163,20 @@ class AgentModelConfig: pool: ModelPool | None = None # 模型池配置 +@dataclass +class NagaConfig: + """Naga 集成配置 + + 面向与 NagaAgent 对接的高级场景,普通用户不建议开启。 + 启用后允许 NagaAgent 通过绑定审批机制向 QQ 群/用户发送回调消息。 + 总开关为 features.nagaagent_mode_enabled。 + """ + + api_url: str = "" + api_key: str = "" + allowed_groups: list[int] = field(default_factory=list) + + @dataclass class CognitiveConfig: """认知记忆系统配置""" diff --git a/src/Undefined/main.py b/src/Undefined/main.py index dd7d6446..573916ab 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -397,6 +397,16 @@ def _apply_config_updates( ) if config.api.enabled: + # Naga 集成(总开关为 features.nagaagent_mode_enabled) + naga_store = None + if config.nagaagent_mode_enabled: + from Undefined.api.naga_store import NagaStore + + naga_store = NagaStore() + await naga_store.load() + handler.command_dispatcher.naga_store = naga_store + logger.info("[Naga] 绑定存储已加载") + runtime_api_context = RuntimeAPIContext( config_getter=lambda: get_config(strict=False), onebot=onebot, @@ -408,6 +418,7 @@ def _apply_config_updates( scheduler=handler.ai_coordinator.scheduler, cognitive_service=cognitive_service, cognitive_job_queue=job_queue, + naga_store=naga_store, ) runtime_api_server = RuntimeAPIServer( runtime_api_context, diff --git a/src/Undefined/services/command.py b/src/Undefined/services/command.py index 6eba3002..36446471 100644 --- a/src/Undefined/services/command.py +++ b/src/Undefined/services/command.py @@ -111,6 +111,7 @@ def __init__( self.security = security self.queue_manager = queue_manager self.rate_limiter = rate_limiter + self.naga_store: Any = None self._token_usage_storage = TokenUsageStorage() # 存储 stats 分析结果,用于队列回调 self._stats_analysis_results: dict[str, str] = {} diff --git a/src/Undefined/skills/commands/help/handler.py b/src/Undefined/skills/commands/help/handler.py index 5843087c..32b87cb1 100644 --- a/src/Undefined/skills/commands/help/handler.py +++ b/src/Undefined/skills/commands/help/handler.py @@ -24,12 +24,32 @@ def _is_private_scope(context: CommandContext) -> bool: return int(context.group_id) == 0 +def _can_see_command(permission: str, sender_id: int, context: CommandContext) -> bool: + """根据命令权限判断用户是否可见该命令。""" + if permission in ("public", ""): + return True + if permission == "superadmin": + return context.config.is_superadmin(sender_id) + if permission == "admin": + return context.config.is_admin(sender_id) or context.config.is_superadmin( + sender_id + ) + return True + + def _format_command_list(context: CommandContext) -> str: commands = context.registry.list_commands(include_hidden=False) in_private = _is_private_scope(context) if in_private: commands = [item for item in commands if item.allow_in_private] + # 按权限过滤:非管理员看不到管理命令 + commands = [ + item + for item in commands + if _can_see_command(item.permission, context.sender_id, context) + ] + command_lines = [ ( f"- {item.usage}({_scope_label(item.allow_in_private)}):" @@ -85,6 +105,8 @@ def _format_command_detail(command_name: str, context: CommandContext) -> str | return None if _is_private_scope(context) and not meta.allow_in_private: return None + if not _can_see_command(meta.permission, context.sender_id, context): + return None aliases = "、".join(f"/{alias}" for alias in meta.aliases) if meta.aliases else "无" doc_content = _load_command_doc(meta.doc_path) diff --git a/src/Undefined/skills/commands/naga/config.json b/src/Undefined/skills/commands/naga/config.json new file mode 100644 index 00000000..5086e70f --- /dev/null +++ b/src/Undefined/skills/commands/naga/config.json @@ -0,0 +1,10 @@ +{ + "name": "naga", + "description": "NagaAgent 集成管理", + "usage": "/naga [参数]", + "permission": "public", + "allow_in_private": true, + "show_in_help": true, + "order": 95, + "aliases": [] +} diff --git a/src/Undefined/skills/commands/naga/handler.py b/src/Undefined/skills/commands/naga/handler.py new file mode 100644 index 00000000..14dd1254 --- /dev/null +++ b/src/Undefined/skills/commands/naga/handler.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path +from uuid import uuid4 + +from aiohttp import ClientSession, ClientTimeout + +from Undefined.api.naga_store import mask_token +from Undefined.services.commands.context import CommandContext + +logger = logging.getLogger(__name__) + +_SCOPES_FILE = Path(__file__).parent / "scopes.json" + + +def _load_scopes() -> dict[str, str]: + try: + with open(_SCOPES_FILE, encoding="utf-8") as f: + data = json.load(f) + return {str(k): str(v) for k, v in data.items()} + except Exception: + return {} + + +_SCOPE_ALIASES: dict[str, str] = { + "admin_only": "admin", + "superadmin_only": "superadmin", +} + + +def _check_scope(subcmd: str, sender_id: int, context: CommandContext) -> str | None: + """检查子命令权限与作用域,返回错误提示或 None 表示通过。 + + 支持的 scope 值: + - ``public`` — 任何人、任何场景 + - ``admin`` / ``admin_only`` — 仅管理员+ + - ``superadmin`` / ``superadmin_only`` — 仅超级管理员 + - ``group_only`` — 任何人,但仅限群聊 + - ``private_only`` — 任何人,但仅限私聊 + """ + scopes = _load_scopes() + raw = scopes.get(subcmd, "superadmin") + scope = _SCOPE_ALIASES.get(raw, raw) + + # 作用域限制 + if scope == "group_only": + if context.scope != "group": + return "该子命令仅限群聊使用" + return None + if scope == "private_only": + if context.scope != "private": + return "该子命令仅限私聊使用" + return None + + # 权限检查 + if scope == "public": + return None + if scope == "superadmin" and context.config.is_superadmin(sender_id): + return None + if scope == "admin" and ( + context.config.is_admin(sender_id) or context.config.is_superadmin(sender_id) + ): + return None + return "权限不足" + + +async def _reply(context: CommandContext, text: str) -> None: + """根据 scope 发送回复""" + if context.scope == "private" and context.user_id is not None: + await context.sender.send_private_message(context.user_id, text) + elif context.group_id: + await context.sender.send_group_message(context.group_id, text) + + +async def execute(args: list[str], context: CommandContext) -> None: + """处理 /naga 命令""" + # 前置检查: naga 是否启用(总开关为 features.nagaagent_mode_enabled) + if not context.config.nagaagent_mode_enabled: + await _reply(context, "Naga 集成未启用") + return + + if not args: + await _reply( + context, + "用法: /naga [参数]\n" + "子命令:\n" + " bind — 提交绑定申请(群聊内使用)\n" + " approve — 通过绑定申请\n" + " reject — 拒绝绑定申请\n" + " revoke — 吊销已有绑定\n" + " list — 列出所有活跃绑定\n" + " pending — 列出待审核申请\n" + " info — 查看绑定详情", + ) + return + + subcmd = args[0].lower() + sub_args = args[1:] + + # 权限检查 + perm_err = _check_scope(subcmd, context.sender_id, context) + if perm_err is not None: + await _reply(context, f"❌ {perm_err}") + return + + # 群聊白名单检查:群聊场景下仅在 allowed_groups 内的群可用 + if context.scope == "group": + if context.group_id not in context.config.naga.allowed_groups: + return + + naga_store = getattr(context.dispatcher, "naga_store", None) + if naga_store is None: + await _reply(context, "❌ NagaStore 未初始化") + return + + handlers: dict[str, object] = { + "bind": _handle_bind, + "approve": _handle_approve, + "reject": _handle_reject, + "revoke": _handle_revoke, + "list": _handle_list, + "pending": _handle_pending, + "info": _handle_info, + } + + handler = handlers.get(subcmd) + if handler is None: + await _reply(context, f"❌ 未知子命令: {subcmd}") + return + + try: + await handler(sub_args, context, naga_store) # type: ignore[operator] + except Exception as exc: + error_id = uuid4().hex[:8] + logger.exception("[NagaCmd] %s 执行失败: error_id=%s", subcmd, error_id) + await _reply(context, f"❌ 操作失败(错误码: {error_id}): {exc}") + + +async def _handle_bind( + args: list[str], context: CommandContext, naga_store: object +) -> None: + """处理 /naga bind """ + from Undefined.api.naga_store import NagaStore + + assert isinstance(naga_store, NagaStore) + + if context.scope != "group": + await _reply(context, "❌ bind 命令仅限群聊中使用") + return + + if not args: + await _reply(context, "用法: /naga bind ") + return + + naga_id = args[0].strip() + if not naga_id: + await _reply(context, "❌ naga_id 不能为空") + return + + ok, msg = await naga_store.submit_binding( + naga_id=naga_id, + qq_id=context.sender_id, + group_id=context.group_id, + ) + + if not ok: + await _reply(context, f"❌ {msg}") + return + + await _reply(context, f"✅ {msg}") + + # 私聊通知超管 + superadmin_qq = context.config.superadmin_qq + if superadmin_qq: + try: + await context.sender.send_private_message( + superadmin_qq, + f"📋 Naga 绑定申请\n" + f"naga_id: {naga_id}\n" + f"申请人 QQ: {context.sender_id}\n" + f"来源群: {context.group_id}\n" + f"使用 /naga approve {naga_id} 或 /naga reject {naga_id} 处理", + ) + except Exception as exc: + logger.warning("[NagaCmd] 通知超管失败: %s", exc) + + +async def _handle_approve( + args: list[str], context: CommandContext, naga_store: object +) -> None: + """处理 /naga approve """ + from Undefined.api.naga_store import NagaStore + + assert isinstance(naga_store, NagaStore) + + if not args: + await _reply(context, "用法: /naga approve ") + return + + naga_id = args[0].strip() + binding = await naga_store.approve(naga_id) + if binding is None: + await _reply(context, f"❌ 未找到 naga_id '{naga_id}' 的待审核申请") + return + + # 调 Naga API 同步 token + sync_ok = await _sync_token_to_naga(context, naga_id, binding.token) + + await _reply( + context, + f"✅ 绑定已通过\n" + f"naga_id: {naga_id}\n" + f"QQ: {binding.qq_id}\n" + f"群: {binding.group_id}\n" + f"Token: {mask_token(binding.token)}\n" + f"Naga 同步: {'成功' if sync_ok else '失败(请手动同步)'}", + ) + + # 私聊通知申请人 + try: + await context.sender.send_private_message( + binding.qq_id, + f"🎉 你的 Naga 绑定申请已通过!\nnaga_id: {naga_id}", + ) + except Exception as exc: + logger.warning("[NagaCmd] 通知申请人失败: %s", exc) + + +async def _handle_reject( + args: list[str], context: CommandContext, naga_store: object +) -> None: + """处理 /naga reject """ + from Undefined.api.naga_store import NagaStore + + assert isinstance(naga_store, NagaStore) + + if not args: + await _reply(context, "用法: /naga reject ") + return + + naga_id = args[0].strip() + + # 获取 pending 信息以通知申请人 + pending_list = naga_store.list_pending() + pending_qq: int | None = None + for p in pending_list: + if p.naga_id == naga_id: + pending_qq = p.qq_id + break + + ok = await naga_store.reject(naga_id) + if not ok: + await _reply(context, f"❌ 未找到 naga_id '{naga_id}' 的待审核申请") + return + + await _reply(context, f"✅ 已拒绝 naga_id '{naga_id}' 的绑定申请") + + # 私聊通知申请人 + if pending_qq: + try: + await context.sender.send_private_message( + pending_qq, + f"❌ 你的 Naga 绑定申请已被拒绝\nnaga_id: {naga_id}", + ) + except Exception as exc: + logger.warning("[NagaCmd] 通知申请人失败: %s", exc) + + +async def _handle_revoke( + args: list[str], context: CommandContext, naga_store: object +) -> None: + """处理 /naga revoke """ + from Undefined.api.naga_store import NagaStore + + assert isinstance(naga_store, NagaStore) + + if not args: + await _reply(context, "用法: /naga revoke ") + return + + naga_id = args[0].strip() + + # 获取绑定信息用于通知和 API 调用 + binding = naga_store.get_binding(naga_id) + if binding is None: + await _reply(context, f"❌ 未找到 naga_id '{naga_id}' 的绑定") + return + + ok = await naga_store.revoke(naga_id) + if not ok: + await _reply(context, f"❌ naga_id '{naga_id}' 已被吊销或不存在") + return + + # 调 Naga API 删除 token + delete_ok = await _delete_token_from_naga(context, naga_id) + + await _reply( + context, + f"✅ 已吊销 naga_id '{naga_id}' 的绑定\n" + f"Naga 同步删除: {'成功' if delete_ok else '失败(请手动处理)'}", + ) + + +async def _handle_list( + _args: list[str], context: CommandContext, naga_store: object +) -> None: + """处理 /naga list""" + from Undefined.api.naga_store import NagaStore + + assert isinstance(naga_store, NagaStore) + + bindings = naga_store.list_bindings() + if not bindings: + await _reply(context, "📋 当前没有活跃绑定") + return + + lines = ["📋 活跃绑定列表:"] + for b in bindings: + lines.append( + f" • {b.naga_id} → QQ:{b.qq_id} 群:{b.group_id} 使用:{b.use_count}次" + ) + await _reply(context, "\n".join(lines)) + + +async def _handle_pending( + _args: list[str], context: CommandContext, naga_store: object +) -> None: + """处理 /naga pending""" + from Undefined.api.naga_store import NagaStore + + assert isinstance(naga_store, NagaStore) + + pending = naga_store.list_pending() + if not pending: + await _reply(context, "📋 当前没有待审核申请") + return + + lines = ["📋 待审核申请:"] + for p in pending: + lines.append(f" • {p.naga_id} ← QQ:{p.qq_id} 群:{p.group_id}") + await _reply(context, "\n".join(lines)) + + +async def _handle_info( + args: list[str], context: CommandContext, naga_store: object +) -> None: + """处理 /naga info """ + from datetime import datetime + + from Undefined.api.naga_store import NagaStore + + assert isinstance(naga_store, NagaStore) + + if not args: + await _reply(context, "用法: /naga info ") + return + + naga_id = args[0].strip() + binding = naga_store.get_binding(naga_id) + if binding is None: + await _reply(context, f"❌ 未找到 naga_id '{naga_id}' 的绑定") + return + + created = datetime.fromtimestamp(binding.created_at).strftime("%Y-%m-%d %H:%M:%S") + last_used = ( + datetime.fromtimestamp(binding.last_used_at).strftime("%Y-%m-%d %H:%M:%S") + if binding.last_used_at + else "从未使用" + ) + + await _reply( + context, + f"📋 绑定详情: {naga_id}\n" + f"Token: {mask_token(binding.token)}\n" + f"QQ: {binding.qq_id}\n" + f"群: {binding.group_id}\n" + f"状态: {'已吊销' if binding.revoked else '活跃'}\n" + f"创建时间: {created}\n" + f"最后使用: {last_used}\n" + f"使用次数: {binding.use_count}", + ) + + +async def _sync_token_to_naga( + context: CommandContext, naga_id: str, token: str +) -> bool: + """调 Naga API 同步 token""" + api_url = context.config.naga.api_url + api_key = context.config.naga.api_key + if not api_url: + logger.warning("[NagaCmd] naga.api_url 未配置,跳过 token 同步") + return False + + url = f"{api_url.rstrip('/')}/api/integration/tokens" + headers: dict[str, str] = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + try: + timeout = ClientTimeout(total=10) + async with ClientSession(timeout=timeout) as session: + async with session.post( + url, + json={"naga_id": naga_id, "token": token}, + headers=headers, + ) as resp: + if resp.status < 300: + logger.info( + "[NagaCmd] Token 同步成功: naga_id=%s status=%d", + naga_id, + resp.status, + ) + return True + body = await resp.text() + logger.warning( + "[NagaCmd] Token 同步失败: naga_id=%s status=%d body=%s", + naga_id, + resp.status, + body[:200], + ) + return False + except Exception as exc: + logger.warning( + "[NagaCmd] Token 同步请求失败: naga_id=%s error=%s", naga_id, exc + ) + return False + + +async def _delete_token_from_naga(context: CommandContext, naga_id: str) -> bool: + """调 Naga API 删除 token""" + api_url = context.config.naga.api_url + api_key = context.config.naga.api_key + if not api_url: + logger.warning("[NagaCmd] naga.api_url 未配置,跳过 token 删除") + return False + + url = f"{api_url.rstrip('/')}/api/integration/tokens/{naga_id}" + headers: dict[str, str] = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + try: + timeout = ClientTimeout(total=10) + async with ClientSession(timeout=timeout) as session: + async with session.delete(url, headers=headers) as resp: + if resp.status < 300: + logger.info( + "[NagaCmd] Token 删除成功: naga_id=%s status=%d", + naga_id, + resp.status, + ) + return True + logger.warning( + "[NagaCmd] Token 删除失败: naga_id=%s status=%d", + naga_id, + resp.status, + ) + return False + except Exception as exc: + logger.warning( + "[NagaCmd] Token 删除请求失败: naga_id=%s error=%s", naga_id, exc + ) + return False diff --git a/src/Undefined/skills/commands/naga/scopes.json b/src/Undefined/skills/commands/naga/scopes.json new file mode 100644 index 00000000..0b0fb0d1 --- /dev/null +++ b/src/Undefined/skills/commands/naga/scopes.json @@ -0,0 +1,9 @@ +{ + "bind": "group_only", + "approve": "superadmin", + "reject": "superadmin", + "revoke": "superadmin", + "list": "superadmin", + "pending": "superadmin", + "info": "superadmin" +} diff --git a/tests/test_naga_store.py b/tests/test_naga_store.py new file mode 100644 index 00000000..841f28d6 --- /dev/null +++ b/tests/test_naga_store.py @@ -0,0 +1,195 @@ +"""NagaStore 单元测试""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from Undefined.api.naga_store import NagaStore, mask_token + + +@pytest.fixture +def store(tmp_path: Path) -> NagaStore: + return NagaStore(data_file=tmp_path / "naga_bindings.json") + + +async def test_submit_binding(store: NagaStore) -> None: + ok, msg = await store.submit_binding("alice", qq_id=123, group_id=456) + assert ok is True + assert "已提交" in msg + + +async def test_submit_duplicate_pending(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + ok, msg = await store.submit_binding("alice", qq_id=789, group_id=456) + assert ok is False + assert "审核队列" in msg + + +async def test_submit_already_bound(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.approve("alice") + ok, msg = await store.submit_binding("alice", qq_id=789, group_id=456) + assert ok is False + assert "已绑定" in msg + + +async def test_approve(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + binding = await store.approve("alice") + assert binding is not None + assert binding.naga_id == "alice" + assert binding.qq_id == 123 + assert binding.group_id == 456 + assert binding.token.startswith("udf_") + assert len(binding.token) == 4 + 48 # "udf_" + 48 hex + + +async def test_approve_nonexistent(store: NagaStore) -> None: + result = await store.approve("nonexistent") + assert result is None + + +async def test_reject(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + ok = await store.reject("alice") + assert ok is True + assert store.list_pending() == [] + + +async def test_reject_nonexistent(store: NagaStore) -> None: + ok = await store.reject("nonexistent") + assert ok is False + + +async def test_revoke(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.approve("alice") + ok = await store.revoke("alice") + assert ok is True + binding = store.get_binding("alice") + assert binding is not None + assert binding.revoked is True + + +async def test_revoke_nonexistent(store: NagaStore) -> None: + ok = await store.revoke("nonexistent") + assert ok is False + + +async def test_revoke_already_revoked(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.approve("alice") + await store.revoke("alice") + ok = await store.revoke("alice") + assert ok is False + + +async def test_verify_valid(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + binding = await store.approve("alice") + assert binding is not None + valid, err = store.verify("alice", binding.token) + assert valid is True + assert err == "" + + +async def test_verify_wrong_token(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.approve("alice") + valid, err = store.verify("alice", "udf_wrong") + assert valid is False + assert "不匹配" in err + + +async def test_verify_revoked(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + binding = await store.approve("alice") + assert binding is not None + await store.revoke("alice") + valid, err = store.verify("alice", binding.token) + assert valid is False + assert "吊销" in err + + +async def test_verify_nonexistent(store: NagaStore) -> None: + valid, err = store.verify("nonexistent", "udf_xxx") + assert valid is False + assert "未绑定" in err + + +async def test_list_bindings(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.approve("alice") + await store.submit_binding("bob", qq_id=789, group_id=456) + await store.approve("bob") + + bindings = store.list_bindings() + assert len(bindings) == 2 + ids = {b.naga_id for b in bindings} + assert ids == {"alice", "bob"} + + +async def test_list_bindings_excludes_revoked(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.approve("alice") + await store.revoke("alice") + + bindings = store.list_bindings() + assert len(bindings) == 0 + + +async def test_list_pending(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.submit_binding("bob", qq_id=789, group_id=456) + + pending = store.list_pending() + assert len(pending) == 2 + + +async def test_record_usage(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.approve("alice") + await store.record_usage("alice") + + binding = store.get_binding("alice") + assert binding is not None + assert binding.use_count == 1 + assert binding.last_used_at is not None + + +async def test_persistence(tmp_path: Path) -> None: + """测试保存后重新加载数据一致性""" + data_file = tmp_path / "naga_bindings.json" + + store1 = NagaStore(data_file=data_file) + await store1.submit_binding("alice", qq_id=123, group_id=456) + binding = await store1.approve("alice") + assert binding is not None + + await store1.submit_binding("bob", qq_id=789, group_id=456) + + # 重新加载 + store2 = NagaStore(data_file=data_file) + await store2.load() + + assert store2.get_binding("alice") is not None + assert store2.get_binding("alice").token == binding.token # type: ignore[union-attr] + assert len(store2.list_pending()) == 1 + + +async def test_submit_after_revoke_allows_rebind(store: NagaStore) -> None: + """吊销后可以重新提交绑定""" + await store.submit_binding("alice", qq_id=123, group_id=456) + await store.approve("alice") + await store.revoke("alice") + ok, _ = await store.submit_binding("alice", qq_id=789, group_id=456) + assert ok is True + + +def test_mask_token() -> None: + assert mask_token("udf_a1b2c3d4e5f6g7h8") == "udf_a1b2c3d4..." + assert mask_token("short") == "short" + assert mask_token("udf_12345678") == "udf_12345678" + assert mask_token("udf_123456789") == "udf_12345678..." From 1a781f73e05ee033611b6706719bfe7b4f7e1f09 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 10:25:06 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(api):=20=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E4=B8=8E=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSRF 防护:_validate_callback_url 拒绝私有/回环 IP 字面量 - 临时文件泄漏:naga callback 渲染图片后 finally 清理 mkstemp 文件 - 后台任务泄漏:RuntimeAPIServer.stop() 先 cancel 再 cleanup - allowed_groups 查找优化:list[int] → frozenset[int],O(1) 查找 - 文档修正:明确群聊场景所有 /naga 子命令受 allowed_groups 白名单限制 Co-Authored-By: Claude Opus 4.6 --- docs/configuration.md | 7 ++- docs/slash-commands.md | 3 +- src/Undefined/api/app.py | 90 ++++++++++++++++++--------- src/Undefined/config/loader.py | 2 +- src/Undefined/config/models.py | 2 +- tests/test_runtime_api_tool_invoke.py | 9 ++- 6 files changed, 77 insertions(+), 36 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9cc94752..195611a9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -650,12 +650,13 @@ model_name = "gpt-4o-mini" | `allowed_groups` | `[]` | Naga 服务群聊名单 | 绑定命令和回调群发仅限名单内的群 | **作用域规则**: -- `/naga bind` 仅在 `allowed_groups` 内的群可用 +- 群聊场景下,所有 `/naga` 子命令仅在 `allowed_groups` 内的群可用 +- 私聊场景不受 `allowed_groups` 限制 - 回调群发仅发到绑定时的群(该群须仍在 `allowed_groups` 内) - 回调私聊只需总开关开启,不受 `allowed_groups` 限制 -- `/api/v1/naga/*` 端点仅在 `enabled=true` 时注册 +- `/api/v1/naga/*` 端点仅在总开关开启时注册 -**数据存储**:绑定数据持久化在 `data/naga_bindings.json`,启动时自动 `chmod 600`。 +**数据存储**:绑定数据持久化在 `data/naga_bindings.json`,Unix 下自动 `chmod 600`。 `naga.*` 变更需要重启进程才能生效。 diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 3e33385e..4a220c57 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -208,7 +208,8 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 ``` - **额外行为**: - - `bind` 仅在 `naga.allowed_groups` 白名单内的群可用,其余群静默忽略。 + - 群聊场景下,所有子命令仅在 `naga.allowed_groups` 白名单内的群可用,非白名单群静默忽略。 + - 私聊场景下不受 `allowed_groups` 限制。 - `approve` 成功后会自动调 Naga API 同步 token 并私聊通知申请人。 - `reject` 成功后私聊通知申请人。 - `revoke` 成功后调 Naga API 删除 token。 diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 8034480c..6e979bb8 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -242,13 +242,32 @@ def _registry_summary(registry: Any) -> dict[str, Any]: def _validate_callback_url(url: str) -> str | None: - """校验回调 URL,返回错误信息或 None 表示通过。""" + """校验回调 URL,返回错误信息或 None 表示通过。 + + 拒绝非 HTTP(S) scheme,以及直接使用私有/回环 IP 字面量的 URL 以防止 SSRF。 + 域名形式的 URL 放行(DNS 解析阶段不适合在校验函数中做阻塞调用)。 + """ + import ipaddress + parsed = urlsplit(url) scheme = (parsed.scheme or "").lower() if scheme not in ("http", "https"): return "callback.url must use http or https" + hostname = parsed.hostname or "" + if not hostname: + return "callback.url must include a hostname" + + # 仅检查 IP 字面量(如 http://127.0.0.1/、http://[::1]/、http://10.0.0.1/) + try: + addr = ipaddress.ip_address(hostname) + except ValueError: + pass # 域名形式,放行 + else: + if addr.is_private or addr.is_loopback or addr.is_link_local: + return "callback.url must not point to a private/loopback address" + return None @@ -479,6 +498,13 @@ async def start(self) -> None: ) async def stop(self) -> None: + # 取消所有后台任务(如异步 tool invoke 回调) + for task in self._background_tasks: + task.cancel() + if self._background_tasks: + await asyncio.gather(*self._background_tasks, return_exceptions=True) + self._background_tasks.clear() + if self._runner is not None: await self._runner.cleanup() logger.info("[RuntimeAPI] 已停止") @@ -1558,6 +1584,7 @@ async def _naga_callback_handler(self, request: web.Request) -> Response: # 5. 按 format 渲染内容 send_content: str | None = None image_path: str | None = None + tmp_path: str | None = None if fmt == "text": send_content = content @@ -1581,43 +1608,50 @@ async def _naga_callback_handler(self, request: web.Request) -> Response: sent_private = False sent_group = False - # 私聊发给绑定的 QQ 用户 try: - if send_content is not None: - await sender.send_private_message(binding.qq_id, send_content) - elif image_path is not None: - cq_image = f"[CQ:image,file=file:///{image_path}]" - await sender.send_private_message(binding.qq_id, cq_image) - sent_private = True - except Exception as exc: - logger.warning( - "[NagaCallback] 私聊发送失败: naga_id=%s qq=%d error=%s", - naga_id, - binding.qq_id, - exc, - ) - - # 群聊发到绑定时的群(须仍在 allowed_groups 内) - if binding.group_id in cfg.naga.allowed_groups: + # 私聊发给绑定的 QQ 用户 try: if send_content is not None: - await sender.send_group_message(binding.group_id, send_content) + await sender.send_private_message(binding.qq_id, send_content) elif image_path is not None: cq_image = f"[CQ:image,file=file:///{image_path}]" - await sender.send_group_message(binding.group_id, cq_image) - sent_group = True + await sender.send_private_message(binding.qq_id, cq_image) + sent_private = True except Exception as exc: logger.warning( - "[NagaCallback] 群聊发送失败: naga_id=%s group=%d error=%s", + "[NagaCallback] 私聊发送失败: naga_id=%s qq=%d error=%s", naga_id, - binding.group_id, + binding.qq_id, exc, ) - else: - logger.info( - "[NagaCallback] 群 %d 不在 allowed_groups 中,跳过群发", - binding.group_id, - ) + + # 群聊发到绑定时的群(须仍在 allowed_groups 内) + if binding.group_id in cfg.naga.allowed_groups: + try: + if send_content is not None: + await sender.send_group_message(binding.group_id, send_content) + elif image_path is not None: + cq_image = f"[CQ:image,file=file:///{image_path}]" + await sender.send_group_message(binding.group_id, cq_image) + sent_group = True + except Exception as exc: + logger.warning( + "[NagaCallback] 群聊发送失败: naga_id=%s group=%d error=%s", + naga_id, + binding.group_id, + exc, + ) + else: + logger.info( + "[NagaCallback] 群 %d 不在 allowed_groups 中,跳过群发", + binding.group_id, + ) + finally: + if tmp_path is not None: + try: + os.unlink(tmp_path) + except OSError: + pass # 7. record_usage await naga_store.record_usage(naga_id) diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index f3b4b2e0..831d2ae7 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -2472,7 +2472,7 @@ def _parse_naga_config(data: dict[str, Any]) -> NagaConfig: api_url = _coerce_str(section.get("api_url"), "") api_key = _coerce_str(section.get("api_key"), "") - allowed_groups = _coerce_int_list(section.get("allowed_groups")) + allowed_groups = frozenset(_coerce_int_list(section.get("allowed_groups"))) return NagaConfig( api_url=api_url, diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index b0c220d4..cc18925b 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -174,7 +174,7 @@ class NagaConfig: api_url: str = "" api_key: str = "" - allowed_groups: list[int] = field(default_factory=list) + allowed_groups: frozenset[int] = field(default_factory=frozenset) @dataclass diff --git a/tests/test_runtime_api_tool_invoke.py b/tests/test_runtime_api_tool_invoke.py index b0134fb3..b0e42a8d 100644 --- a/tests/test_runtime_api_tool_invoke.py +++ b/tests/test_runtime_api_tool_invoke.py @@ -134,8 +134,6 @@ async def _json() -> dict[str, Any]: def test_callback_url_allows_http() -> None: assert _validate_callback_url("http://example.com/hook") is None assert _validate_callback_url("http://localhost:8000/hook") is None - assert _validate_callback_url("http://127.0.0.1:9000/hook") is None - assert _validate_callback_url("http://192.168.1.1/hook") is None def test_callback_url_allows_https() -> None: @@ -148,6 +146,13 @@ def test_callback_url_rejects_bad_scheme() -> None: assert "http" in err +def test_callback_url_rejects_private_ip() -> None: + assert _validate_callback_url("http://127.0.0.1:9000/hook") is not None + assert _validate_callback_url("http://192.168.1.1/hook") is not None + assert _validate_callback_url("http://10.0.0.1/hook") is not None + assert _validate_callback_url("http://[::1]/hook") is not None + + # --------------------------------------------------------------------------- # _get_filtered_tools tests # --------------------------------------------------------------------------- From def9fa4551fb0c634f188412679da28568f014cc Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 10:45:47 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E8=B7=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7=E4=BF=AE=E5=A4=8D=E4=B8=8E=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CQ image URI:改用 Path.as_uri() 构建跨平台 file URI(Win/Mac/Linux) - _load_scopes() 异步化:拆分 sync + asyncio.to_thread,遵循项目 IO 规范 - openapi.md:修正已删除的 [naga].enabled 引用为 features.nagaagent_mode_enabled Co-Authored-By: Claude Opus 4.6 --- docs/openapi.md | 3 +-- src/Undefined/api/app.py | 13 +++++++++---- src/Undefined/skills/commands/naga/handler.py | 15 +++++++++++---- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/openapi.md b/docs/openapi.md index a46f359d..16da20bc 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -435,13 +435,12 @@ WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 Header ## 8. Naga 回调 API -Naga 集成端点仅在 `[naga].enabled = true` 时注册。这些端点**不走主 API Key 中间件**,使用独立的共享密钥鉴权。 +Naga 集成端点仅在 `[features].nagaagent_mode_enabled = true` 时注册。这些端点**不走主 API Key 中间件**,使用独立的共享密钥鉴权。 ### 配置 ```toml [naga] -enabled = false api_url = "" api_key = "" allowed_groups = [] diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 6e979bb8..0f1cdc95 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -5,6 +5,7 @@ import logging import os import platform +from pathlib import Path import socket import sys import time @@ -1609,12 +1610,17 @@ async def _naga_callback_handler(self, request: web.Request) -> Response: sent_group = False try: + # 构建图片 CQ 码(跨平台 URI) + cq_image: str | None = None + if image_path is not None: + file_uri = Path(image_path).resolve().as_uri() + cq_image = f"[CQ:image,file={file_uri}]" + # 私聊发给绑定的 QQ 用户 try: if send_content is not None: await sender.send_private_message(binding.qq_id, send_content) - elif image_path is not None: - cq_image = f"[CQ:image,file=file:///{image_path}]" + elif cq_image is not None: await sender.send_private_message(binding.qq_id, cq_image) sent_private = True except Exception as exc: @@ -1630,8 +1636,7 @@ async def _naga_callback_handler(self, request: web.Request) -> Response: try: if send_content is not None: await sender.send_group_message(binding.group_id, send_content) - elif image_path is not None: - cq_image = f"[CQ:image,file=file:///{image_path}]" + elif cq_image is not None: await sender.send_group_message(binding.group_id, cq_image) sent_group = True except Exception as exc: diff --git a/src/Undefined/skills/commands/naga/handler.py b/src/Undefined/skills/commands/naga/handler.py index 14dd1254..dbcf7267 100644 --- a/src/Undefined/skills/commands/naga/handler.py +++ b/src/Undefined/skills/commands/naga/handler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import logging from pathlib import Path @@ -15,7 +16,7 @@ _SCOPES_FILE = Path(__file__).parent / "scopes.json" -def _load_scopes() -> dict[str, str]: +def _load_scopes_sync() -> dict[str, str]: try: with open(_SCOPES_FILE, encoding="utf-8") as f: data = json.load(f) @@ -24,13 +25,19 @@ def _load_scopes() -> dict[str, str]: return {} +async def _load_scopes() -> dict[str, str]: + return await asyncio.to_thread(_load_scopes_sync) + + _SCOPE_ALIASES: dict[str, str] = { "admin_only": "admin", "superadmin_only": "superadmin", } -def _check_scope(subcmd: str, sender_id: int, context: CommandContext) -> str | None: +async def _check_scope( + subcmd: str, sender_id: int, context: CommandContext +) -> str | None: """检查子命令权限与作用域,返回错误提示或 None 表示通过。 支持的 scope 值: @@ -40,7 +47,7 @@ def _check_scope(subcmd: str, sender_id: int, context: CommandContext) -> str | - ``group_only`` — 任何人,但仅限群聊 - ``private_only`` — 任何人,但仅限私聊 """ - scopes = _load_scopes() + scopes = await _load_scopes() raw = scopes.get(subcmd, "superadmin") scope = _SCOPE_ALIASES.get(raw, raw) @@ -100,7 +107,7 @@ async def execute(args: list[str], context: CommandContext) -> None: sub_args = args[1:] # 权限检查 - perm_err = _check_scope(subcmd, context.sender_id, context) + perm_err = await _check_scope(subcmd, context.sender_id, context) if perm_err is not None: await _reply(context, f"❌ {perm_err}") return From 838916b402d04f856fb0faa1cdbf9c5b7737bc28 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 11:05:57 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(naga):=20=E7=A7=81=E8=81=8A=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=8F=91=E9=94=99=E4=BA=BA=E3=80=81=E7=83=AD=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=AD=A6=E5=91=8A=E7=BC=BA=E5=A4=B1=E3=80=81auth=20by?= =?UTF-8?q?pass=20=E6=97=A0=E6=9D=A1=E4=BB=B6=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 私聊代理绕过:approve/reject 通知申请人时使用 _notify_user 绕过 _PrivateCommandSenderProxy,确保消息发给目标用户而非命令调用者 - 热更新警告:_RESTART_REQUIRED_KEYS 加入 "naga",变更时提示需重启 - auth 中间件:/api/v1/naga/ 路径的认证跳过改为仅在 nagaagent_mode_enabled 时生效,关闭时所有 /api/ 路径统一走主认证 Co-Authored-By: Claude Opus 4.6 --- src/Undefined/api/app.py | 21 +++++++++++-------- src/Undefined/config/hot_reload.py | 1 + src/Undefined/skills/commands/naga/handler.py | 19 ++++++++++++----- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 0f1cdc95..b36ef14e 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -523,15 +523,18 @@ async def _auth_middleware( response = web.Response(status=204) _apply_cors_headers(request, response) return response - if request.path.startswith("/api/") and not request.path.startswith( - "/api/v1/naga/" - ): - expected = str(self._context.config_getter().api.auth_key or "") - provided = request.headers.get(_AUTH_HEADER, "") - if not expected or provided != expected: - response = _json_error("Unauthorized", status=401) - _apply_cors_headers(request, response) - return response + if request.path.startswith("/api/"): + # Naga 端点使用独立鉴权,仅在 naga 模式启用时跳过主 auth + cfg = self._context.config_getter() + is_naga_path = request.path.startswith("/api/v1/naga/") + skip_auth = is_naga_path and cfg.nagaagent_mode_enabled + if not skip_auth: + expected = str(cfg.api.auth_key or "") + provided = request.headers.get(_AUTH_HEADER, "") + if not expected or provided != expected: + response = _json_error("Unauthorized", status=401) + _apply_cors_headers(request, response) + return response response = await handler(request) _apply_cors_headers(request, response) return response diff --git a/src/Undefined/config/hot_reload.py b/src/Undefined/config/hot_reload.py index 47b97413..09c6565e 100644 --- a/src/Undefined/config/hot_reload.py +++ b/src/Undefined/config/hot_reload.py @@ -33,6 +33,7 @@ "api.port", "api.auth_key", "api.openapi_enabled", + "naga", } _QUEUE_INTERVAL_KEYS: set[str] = { diff --git a/src/Undefined/skills/commands/naga/handler.py b/src/Undefined/skills/commands/naga/handler.py index dbcf7267..f579df0e 100644 --- a/src/Undefined/skills/commands/naga/handler.py +++ b/src/Undefined/skills/commands/naga/handler.py @@ -81,6 +81,12 @@ async def _reply(context: CommandContext, text: str) -> None: await context.sender.send_group_message(context.group_id, text) +async def _notify_user(context: CommandContext, user_id: int, text: str) -> None: + """直接私聊通知指定用户(绕过私聊代理,确保消息发给目标用户而非命令调用者)""" + real_sender = getattr(context.dispatcher, "sender", context.sender) + await real_sender.send_private_message(user_id, text) + + async def execute(args: list[str], context: CommandContext) -> None: """处理 /naga 命令""" # 前置检查: naga 是否启用(总开关为 features.nagaagent_mode_enabled) @@ -182,7 +188,8 @@ async def _handle_bind( superadmin_qq = context.config.superadmin_qq if superadmin_qq: try: - await context.sender.send_private_message( + await _notify_user( + context, superadmin_qq, f"📋 Naga 绑定申请\n" f"naga_id: {naga_id}\n" @@ -225,9 +232,10 @@ async def _handle_approve( f"Naga 同步: {'成功' if sync_ok else '失败(请手动同步)'}", ) - # 私聊通知申请人 + # 私聊通知申请人(绕过代理,确保发给申请人而非调用者) try: - await context.sender.send_private_message( + await _notify_user( + context, binding.qq_id, f"🎉 你的 Naga 绑定申请已通过!\nnaga_id: {naga_id}", ) @@ -264,10 +272,11 @@ async def _handle_reject( await _reply(context, f"✅ 已拒绝 naga_id '{naga_id}' 的绑定申请") - # 私聊通知申请人 + # 私聊通知申请人(绕过代理,确保发给申请人而非调用者) if pending_qq: try: - await context.sender.send_private_message( + await _notify_user( + context, pending_qq, f"❌ 你的 Naga 绑定申请已被拒绝\nnaga_id: {naga_id}", ) From c49cf85ce593829d781ecaad5d58532aa7166d19 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 11:29:50 +0800 Subject: [PATCH 08/10] =?UTF-8?q?feat(naga):=20=E5=88=86=E7=A6=BB=20Naga?= =?UTF-8?q?=20=E5=BC=80=E5=85=B3=E5=B1=82=E7=BA=A7=20+=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20WebUI=20=E5=A4=9A=E8=A1=8C=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开关分层: - features.nagaagent_mode_enabled — 总开关,控制 AI 侧行为(提示词、工具暴露) - naga.enabled — 子开关,控制外部网关集成(回调 API、/naga 命令、绑定管理) - nagaagent_mode_enabled=false 时强制关闭所有 Naga 功能 WebUI 多行注释修复: - comment.py: 多行同语言注释用换行符连接而非空格压缩 - toml_render.py: 渲染时按换行符拆分为多行 # zh:/en: 输出 Co-Authored-By: Claude Opus 4.6 --- config.toml.example | 23 +++++++++++++++---- docs/configuration.md | 20 ++++++++++++---- docs/openapi.md | 3 ++- docs/slash-commands.md | 4 ++-- src/Undefined/api/app.py | 14 +++++++---- src/Undefined/config/loader.py | 2 ++ src/Undefined/config/models.py | 10 ++++++-- src/Undefined/main.py | 4 ++-- src/Undefined/skills/commands/naga/handler.py | 4 ++-- src/Undefined/webui/utils/comment.py | 6 ++--- src/Undefined/webui/utils/toml_render.py | 9 +++++--- tests/test_webui_render_toml.py | 16 +++++++++++++ 12 files changed, 86 insertions(+), 29 deletions(-) diff --git a/config.toml.example b/config.toml.example index 8c3e706f..b186b773 100644 --- a/config.toml.example +++ b/config.toml.example @@ -856,13 +856,26 @@ failed_cleanup_interval = 100 # en: Max auto-retries per job before moving to failed (0=no retry). job_max_retries = 3 -# zh: Naga 集成配置。总开关为 [features].nagaagent_mode_enabled, -# zh: 启用后允许 NagaAgent 通过绑定审批机制向 QQ 群/用户发送回调消息。 +# zh: Naga 外部网关集成配置。 +# zh: 开关分层: +# zh: - [features].nagaagent_mode_enabled — 总开关,控制 AI 侧行为(提示词切换、工具暴露) +# zh: - [naga].enabled — 子开关,控制外部网关集成(回调 API、/naga 命令、绑定管理) +# zh: 仅当两者均为 true 时,外部网关集成才生效。 +# zh: 若只需 NagaAgent 解答能力而不需要外部回调联动,可只开启 nagaagent_mode_enabled。 # zh: ⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。 -# en: Naga integration settings. Master switch is [features].nagaagent_mode_enabled. -# en: When enabled, NagaAgent can send callback messages to QQ groups/users through a binding approval mechanism. -# en: ⚠️ This feature is for advanced NagaAgent integration scenarios. Not recommended for regular users. +# en: Naga external gateway integration settings. +# en: Switch hierarchy: +# en: - [features].nagaagent_mode_enabled — master switch for AI behavior (prompt, tool exposure) +# en: - [naga].enabled — sub-switch for external gateway (callback API, /naga command, bindings) +# en: Both must be true for external gateway integration to work. +# en: To get NagaAgent answering without external callbacks, only enable nagaagent_mode_enabled. +# en: ⚠️ Advanced feature for NagaAgent integration. Not recommended for regular users. [naga] +# zh: 是否启用外部网关集成(回调 API、/naga 命令、绑定管理)。 +# zh: 需同时开启 [features].nagaagent_mode_enabled 才生效。 +# en: Enable external gateway integration (callback API, /naga command, bindings). +# en: Requires [features].nagaagent_mode_enabled = true to take effect. +enabled = false # zh: Naga 服务器 API 地址。 # en: Naga server API URL. api_url = "" diff --git a/docs/configuration.md b/docs/configuration.md index 195611a9..dd41f87c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -635,16 +635,26 @@ model_name = "gpt-4o-mini" --- -### 4.25 `[naga]` Naga 集成 +### 4.25 `[naga]` Naga 外部网关集成 > **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** 启用后允许 NagaAgent 通过绑定审批机制向 QQ 群/用户发送回调消息。鉴权采用双层模型:共享密钥 `api_key` 验证服务器身份 + 每个绑定独立的 scoped token 验证调用权限。 -**总开关**:`[features].nagaagent_mode_enabled = true`。Naga 集成所有功能(`/naga` 命令、回调端点、绑定存储)均受此总开关控制。 +**开关分层**: + +| 开关 | 控制范围 | 默认值 | +|------|---------|--------| +| `[features].nagaagent_mode_enabled` | 总开关:AI 侧行为(提示词切换、工具暴露) | `false` | +| `[naga].enabled` | 子开关:外部网关集成(回调 API、`/naga` 命令、绑定管理) | `false` | + +- 仅当两者均为 `true` 时,外部网关集成生效(API 端点注册、`/naga` 命令可用) +- 若只需 NagaAgent 解答能力而不需要外部回调联动,可只开启 `nagaagent_mode_enabled` +- `nagaagent_mode_enabled = false` 时强制关闭所有 Naga 功能,无论 `naga.enabled` 值 | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| +| `enabled` | `false` | 是否启用外部网关集成 | 需同时开启 `nagaagent_mode_enabled` | | `api_url` | `""` | Naga 服务器 API 地址 | 为空时 token 同步/删除操作跳过 | | `api_key` | `""` | Undefined ↔ Naga 共享密钥 | 回调端点通过 `Authorization: Bearer` 校验 | | `allowed_groups` | `[]` | Naga 服务群聊名单 | 绑定命令和回调群发仅限名单内的群 | @@ -653,8 +663,8 @@ model_name = "gpt-4o-mini" - 群聊场景下,所有 `/naga` 子命令仅在 `allowed_groups` 内的群可用 - 私聊场景不受 `allowed_groups` 限制 - 回调群发仅发到绑定时的群(该群须仍在 `allowed_groups` 内) -- 回调私聊只需总开关开启,不受 `allowed_groups` 限制 -- `/api/v1/naga/*` 端点仅在总开关开启时注册 +- 回调私聊只需开关开启,不受 `allowed_groups` 限制 +- `/api/v1/naga/*` 端点仅在两个开关均开启时注册 **数据存储**:绑定数据持久化在 `data/naga_bindings.json`,Unix 下自动 `chmod 600`。 @@ -682,7 +692,7 @@ model_name = "gpt-4o-mini" - `webui.port` - `webui.password` - `api.*`(`enabled/host/port/auth_key/openapi_enabled`) -- `naga.*`(`api_url/api_key/allowed_groups`) +- `naga.*`(`enabled/api_url/api_key/allowed_groups`) ### 5.3 明确“会执行热应用”的字段 - 模型发车间隔 / 模型名 / 模型池变更(队列间隔刷新) diff --git a/docs/openapi.md b/docs/openapi.md index 16da20bc..fd28e6be 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -435,12 +435,13 @@ WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 Header ## 8. Naga 回调 API -Naga 集成端点仅在 `[features].nagaagent_mode_enabled = true` 时注册。这些端点**不走主 API Key 中间件**,使用独立的共享密钥鉴权。 +Naga 集成端点仅在 `[features].nagaagent_mode_enabled = true` 且 `[naga].enabled = true` 时注册。这些端点**不走主 API Key 中间件**,使用独立的共享密钥鉴权。 ### 配置 ```toml [naga] +enabled = false api_url = "" api_key = "" allowed_groups = [] diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 4a220c57..921ea941 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -176,11 +176,11 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 #### 6. Naga 集成管理 -> **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** 需要先在 `config.toml` 中启用 `[naga]` 配置。 +> **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** 需要在 `config.toml` 中同时启用 `[features].nagaagent_mode_enabled` 和 `[naga].enabled`。 - **/naga \<子命令\> [参数]** - **说明**:NagaAgent 绑定管理。通过子命令完成绑定申请、审批、吊销和查询。 - - **前置条件**:`config.toml` 中 `features.nagaagent_mode_enabled = true`。 + - **前置条件**:`features.nagaagent_mode_enabled = true` 且 `naga.enabled = true`。 **子命令列表**: diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index b36ef14e..d05d4c18 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -524,10 +524,12 @@ async def _auth_middleware( _apply_cors_headers(request, response) return response if request.path.startswith("/api/"): - # Naga 端点使用独立鉴权,仅在 naga 模式启用时跳过主 auth + # Naga 端点使用独立鉴权,仅在总开关+子开关均启用时跳过主 auth cfg = self._context.config_getter() is_naga_path = request.path.startswith("/api/v1/naga/") - skip_auth = is_naga_path and cfg.nagaagent_mode_enabled + skip_auth = ( + is_naga_path and cfg.nagaagent_mode_enabled and cfg.naga.enabled + ) if not skip_auth: expected = str(cfg.api.auth_key or "") provided = request.headers.get(_AUTH_HEADER, "") @@ -563,9 +565,13 @@ async def _auth_middleware( web.post("/api/v1/tools/invoke", self._tools_invoke_handler), ] ) - # Naga 端点仅在启用时注册(总开关为 features.nagaagent_mode_enabled) + # Naga 端点仅在总开关+子开关均启用时注册 cfg = self._context.config_getter() - if cfg.nagaagent_mode_enabled and self._context.naga_store is not None: + if ( + cfg.nagaagent_mode_enabled + and cfg.naga.enabled + and self._context.naga_store is not None + ): app.add_routes( [ web.post("/api/v1/naga/callback", self._naga_callback_handler), diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 831d2ae7..a2296cf1 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -2470,11 +2470,13 @@ def _parse_naga_config(data: dict[str, Any]) -> NagaConfig: section_raw = data.get("naga", {}) section = section_raw if isinstance(section_raw, dict) else {} + enabled = _coerce_bool(section.get("enabled"), False) api_url = _coerce_str(section.get("api_url"), "") api_key = _coerce_str(section.get("api_key"), "") allowed_groups = frozenset(_coerce_int_list(section.get("allowed_groups"))) return NagaConfig( + enabled=enabled, api_url=api_url, api_key=api_key, allowed_groups=allowed_groups, diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index cc18925b..a5263102 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -168,10 +168,16 @@ class NagaConfig: """Naga 集成配置 面向与 NagaAgent 对接的高级场景,普通用户不建议开启。 - 启用后允许 NagaAgent 通过绑定审批机制向 QQ 群/用户发送回调消息。 - 总开关为 features.nagaagent_mode_enabled。 + + 开关分层: + - ``features.nagaagent_mode_enabled`` — 控制 AI 侧行为(提示词切换、工具暴露) + - ``naga.enabled`` — 控制外部网关集成(回调 API、/naga 命令、绑定管理) + + 两者均默认 False。可单独开启 ``nagaagent_mode_enabled`` 获得 NagaAgent 解答能力, + 无需启用外部回调联动。 """ + enabled: bool = False api_url: str = "" api_key: str = "" allowed_groups: frozenset[int] = field(default_factory=frozenset) diff --git a/src/Undefined/main.py b/src/Undefined/main.py index 573916ab..14d1d5e8 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -397,9 +397,9 @@ def _apply_config_updates( ) if config.api.enabled: - # Naga 集成(总开关为 features.nagaagent_mode_enabled) + # Naga 外部网关集成(需同时开启 nagaagent_mode_enabled 和 naga.enabled) naga_store = None - if config.nagaagent_mode_enabled: + if config.nagaagent_mode_enabled and config.naga.enabled: from Undefined.api.naga_store import NagaStore naga_store = NagaStore() diff --git a/src/Undefined/skills/commands/naga/handler.py b/src/Undefined/skills/commands/naga/handler.py index f579df0e..64007aa0 100644 --- a/src/Undefined/skills/commands/naga/handler.py +++ b/src/Undefined/skills/commands/naga/handler.py @@ -89,8 +89,8 @@ async def _notify_user(context: CommandContext, user_id: int, text: str) -> None async def execute(args: list[str], context: CommandContext) -> None: """处理 /naga 命令""" - # 前置检查: naga 是否启用(总开关为 features.nagaagent_mode_enabled) - if not context.config.nagaagent_mode_enabled: + # 前置检查: 需同时开启 nagaagent_mode_enabled(总开关)和 naga.enabled(网关子开关) + if not context.config.nagaagent_mode_enabled or not context.config.naga.enabled: await _reply(context, "Naga 集成未启用") return diff --git a/src/Undefined/webui/utils/comment.py b/src/Undefined/webui/utils/comment.py index 53bc2353..b4228f9a 100644 --- a/src/Undefined/webui/utils/comment.py +++ b/src/Undefined/webui/utils/comment.py @@ -20,9 +20,9 @@ def _normalize_comment_buffer(buffer: list[str]) -> dict[str, str]: parts.setdefault("en", []).append(item[3:].strip()) else: parts.setdefault("default", []).append(item) - default = " ".join(parts.get("default", [])).strip() - zh_value = " ".join(parts.get("zh", [])).strip() - en_value = " ".join(parts.get("en", [])).strip() + default = "\n".join(parts.get("default", [])).strip() + zh_value = "\n".join(parts.get("zh", [])).strip() + en_value = "\n".join(parts.get("en", [])).strip() result: dict[str, str] = {} if zh_value: result["zh"] = zh_value diff --git a/src/Undefined/webui/utils/toml_render.py b/src/Undefined/webui/utils/toml_render.py index ed2538a3..cfeb7174 100644 --- a/src/Undefined/webui/utils/toml_render.py +++ b/src/Undefined/webui/utils/toml_render.py @@ -73,14 +73,17 @@ def _comment_lines(comments: CommentMap | None, path_key: str) -> list[str]: zh = str(entry.get("zh", "")).strip() en = str(entry.get("en", "")).strip() if zh: - lines.append(f"# zh: {zh}") + for part in zh.split("\n"): + lines.append(f"# zh: {part}") if en: - lines.append(f"# en: {en}") + for part in en.split("\n"): + lines.append(f"# en: {part}") if not lines: for value in entry.values(): text = str(value).strip() if text: - lines.append(f"# {text}") + for part in text.split("\n"): + lines.append(f"# {part}") return lines diff --git a/tests/test_webui_render_toml.py b/tests/test_webui_render_toml.py index 5298b435..67d0f111 100644 --- a/tests/test_webui_render_toml.py +++ b/tests/test_webui_render_toml.py @@ -151,6 +151,22 @@ def test_render_comments_before_scalar_keys(self) -> None: assert "# en: Bot QQ number." in rendered assert "bot_qq = 1" in rendered + def test_multiline_comments_preserved(self) -> None: + """多行 zh/en 注释应保留为多行而非压成一行""" + src = ( + "# zh: 第一行\n" + "# zh: 第二行\n" + "# en: Line one\n" + "# en: Line two\n" + "enabled = false\n" + ) + comments = parse_comment_map_text(src) + rendered = render_toml({"enabled": False}, comments=comments) + assert "# zh: 第一行" in rendered + assert "# zh: 第二行" in rendered + assert "# en: Line one" in rendered + assert "# en: Line two" in rendered + def test_multiline_string_roundtrip(self) -> None: """多行字符串应被渲染成合法 TOML,并可完整往返""" original = "第一行\n第二行\n第三行" From e0cda8392dfa6f2308c96d92715c667b56a640bb Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 11:34:54 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(naga):=20render=5Fmarkdown=5Fto=5Fhtm?= =?UTF-8?q?l=20=E5=BC=82=E5=B8=B8=E6=9C=AA=E6=8D=95=E8=8E=B7=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=9B=9E=E8=B0=83=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdown 格式回调中 render_markdown_to_html 未包裹 try/except, 异常时直接 500 而非回退文本发送。现与 render_html_to_image 一致, 失败时回退到纯文本。 Co-Authored-By: Claude Opus 4.6 --- src/Undefined/api/app.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index d05d4c18..5829317a 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -1601,18 +1601,25 @@ async def _naga_callback_handler(self, request: web.Request) -> Response: elif fmt in ("markdown", "html"): import tempfile - html_str = content + html_str: str | None = content if fmt == "markdown": - html_str = await render_markdown_to_html(content) - fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="naga_cb_") - os.close(fd) - try: - await render_html_to_image(html_str, tmp_path) - image_path = tmp_path - except Exception as exc: - logger.warning("[NagaCallback] 渲染失败: %s", exc) - # 回退到文本发送 - send_content = content + try: + html_str = await render_markdown_to_html(content) + except Exception as exc: + logger.warning( + "[NagaCallback] Markdown 转换失败,回退文本: %s", exc + ) + send_content = content + html_str = None + if html_str is not None: + fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="naga_cb_") + os.close(fd) + try: + await render_html_to_image(html_str, tmp_path) + image_path = tmp_path + except Exception as exc: + logger.warning("[NagaCallback] 图片渲染失败,回退文本: %s", exc) + send_content = content # 6. 发送消息 sent_private = False From 39ec088ae4e39b33dd067da339c1498ec80d714a Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 14 Mar 2026 12:06:47 +0800 Subject: [PATCH 10/10] chore(version): bump version to 3.2.1 --- src/Undefined/api/app.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 5829317a..d05d4c18 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -1601,25 +1601,18 @@ async def _naga_callback_handler(self, request: web.Request) -> Response: elif fmt in ("markdown", "html"): import tempfile - html_str: str | None = content + html_str = content if fmt == "markdown": - try: - html_str = await render_markdown_to_html(content) - except Exception as exc: - logger.warning( - "[NagaCallback] Markdown 转换失败,回退文本: %s", exc - ) - send_content = content - html_str = None - if html_str is not None: - fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="naga_cb_") - os.close(fd) - try: - await render_html_to_image(html_str, tmp_path) - image_path = tmp_path - except Exception as exc: - logger.warning("[NagaCallback] 图片渲染失败,回退文本: %s", exc) - send_content = content + html_str = await render_markdown_to_html(content) + fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="naga_cb_") + os.close(fd) + try: + await render_html_to_image(html_str, tmp_path) + image_path = tmp_path + except Exception as exc: + logger.warning("[NagaCallback] 渲染失败: %s", exc) + # 回退到文本发送 + send_content = content # 6. 发送消息 sent_private = False