diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dc27272..0ae71f80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,47 @@ jobs: - name: Install Python dependencies run: uv sync --group ci -p ${{ env.PYTHON_VERSION }} + - name: Validate release tag matches package version + env: + TAG_NAME: ${{ github.ref_name }} + run: | + uv run python - <<'PY' + import os + import pathlib + import re + import sys + import tomllib + + tag_name = os.environ["TAG_NAME"] + expected_version = tag_name.removeprefix("v") + + pyproject = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8")) + project_version = str(pyproject["project"]["version"]).strip() + + init_text = pathlib.Path("src/Undefined/__init__.py").read_text(encoding="utf-8") + match = re.search(r'__version__\s*=\s*"([^"]+)"', init_text) + if match is None: + raise SystemExit("Could not find __version__ in src/Undefined/__init__.py") + init_version = match.group(1).strip() + + errors: list[str] = [] + if project_version != init_version: + errors.append( + f"Version mismatch: pyproject.toml={project_version}, src/Undefined/__init__.py={init_version}" + ) + if project_version != expected_version: + errors.append( + f"Tag/version mismatch: tag={tag_name}, expected package version={expected_version}, actual={project_version}" + ) + + if errors: + raise SystemExit("\n".join(errors)) + + print( + f"Validated release version {project_version} from tag {tag_name}, pyproject.toml, and src/Undefined/__init__.py" + ) + PY + - name: Cache Ruff uses: actions/cache@v4 with: @@ -466,5 +507,31 @@ jobs: name: python-dist path: dist + - name: Verify downloaded distributions + env: + TAG_NAME: ${{ github.ref_name }} + run: | + uv run python - <<'PY' + import os + import pathlib + + expected_version = os.environ["TAG_NAME"].removeprefix("v") + dist_dir = pathlib.Path("dist") + files = sorted(path.name for path in dist_dir.glob("*")) + if not files: + raise SystemExit("No Python distributions were downloaded") + + bad = [name for name in files if f"-{expected_version}" not in name] + if bad: + raise SystemExit( + "Downloaded distributions do not match release tag version " + f"{expected_version}: {bad}" + ) + + print("Distributions ready for publish:") + for name in files: + print(f" - {name}") + PY + - name: Publish to PyPI - run: uv publish dist/* + run: uv publish --check-url https://pypi.org/simple/ dist/* diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index 30a7ce35..84faab8f 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.2.3", + "version": "3.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.2.3", + "version": "3.2.4", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index 7df5aa10..daf2116a 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.2.3", + "version": "3.2.4", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index af5076cb..5d63a733 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.2.3" +version = "3.2.4" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index 609f0f4f..378e987e 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.2.3" +version = "3.2.4" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index c6031c6d..6a5a8112 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.2.3", + "version": "3.2.4", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/config.toml.example b/config.toml.example index c6705d68..577159ec 100644 --- a/config.toml.example +++ b/config.toml.example @@ -243,6 +243,61 @@ responses_force_stateless_replay = false # en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. [models.security.request_params] +# zh: Naga 外发消息审核模型配置(仅用于 Naga 通过 Runtime API 发消息前的内容审核)。 +# zh: 若无必要可不填写;未配置时回退到 [models.security]。 +# en: Naga outbound moderation model config (used only before Naga sends messages through the Runtime API). +# en: Optional; falls back to [models.security] when omitted. +[models.naga] +# zh: OpenAI-compatible 基址 URL,例如 https://api.openai.com/v1(legacy "/chat/completions" 已弃用但仍兼容)。 +# en: OpenAI-compatible base URL, e.g. https://api.openai.com/v1. Note: legacy "/chat/completions" is deprecated but still supported. +api_url = "" +# zh: Naga 审核模型 API Key。 +# en: Naga moderation model API key. +api_key = "" +# zh: Naga 审核模型名称。 +# en: Naga moderation model name. +model_name = "" +# zh: 可选限制:最大生成 tokens。 +# en: Optional limit: max generation tokens. +max_tokens = 160 +# zh: 队列发车间隔(秒)。 +# en: Queue interval (seconds). +queue_interval_seconds = 1.0 +# zh: API 模式:传统 chat.completions 或新版 responses。 +# en: API mode: classic chat.completions or the newer responses API. +api_mode = "chat_completions" +# zh: 是否启用 reasoning.effort。 +# en: Enable reasoning.effort. +reasoning_enabled = false +# zh: reasoning effort 档位。 +# en: reasoning effort level. +reasoning_effort = "medium" +# zh: 是否启用 thinking(思维链)。 +# en: Enable thinking (reasoning). +thinking_enabled = false +# zh: thinking 预算 tokens。 +# en: Thinking-budget tokens. +thinking_budget_tokens = 0 +# zh: 是否在请求中发送 budget_tokens(关闭后由提供商决定思维预算)。 +# en: Whether to include budget_tokens in the request (if disabled, the provider decides the thinking budget). +thinking_include_budget = true +# zh: reasoning effort 传参风格:openai(reasoning.effort)/ anthropic(output_config.effort)。 +# en: Reasoning effort wire format: openai (reasoning.effort) / anthropic (output_config.effort). +reasoning_effort_style = "openai" +# zh: 思维链工具调用兼容:启用后在多轮工具调用中回传 reasoning_content,避免部分模型返回 400。 +# en: Thinking tool-call compatibility: pass back reasoning_content in multi-turn tool calls to avoid 400 errors from some models. +thinking_tool_call_compat = true +# zh: Responses API 的 tool_choice 兼容模式。 +# en: Responses API tool_choice compatibility mode. +responses_tool_choice_compat = false +# zh: Responses API 续轮强制降级。 +# en: Responses API force stateless replay. +responses_force_stateless_replay = false + +# zh: 额外请求体参数(可选),可用于 temperature 或供应商私有参数。 +# en: Extra request-body params (optional), e.g. temperature or vendor-specific fields. +[models.naga.request_params] + # zh: Agent 模型配置(用于执行 agents)。 # en: Agent model config (used to run agents). [models.agent] @@ -886,9 +941,9 @@ job_max_retries = 3 # 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: 是否启用外部网关集成(/naga bind/unbind、远端绑定回调、消息发送、解绑 API)。 # zh: 需同时开启 [features].nagaagent_mode_enabled 才生效。 -# en: Enable external gateway integration (callback API, /naga command, bindings). +# en: Enable external gateway integration (/naga bind/unbind, bind callback, message send, unbind API). # en: Requires [features].nagaagent_mode_enabled = true to take effect. enabled = false # zh: Naga 服务器 API 地址。 @@ -897,6 +952,6 @@ 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. +# zh: Naga 白名单群:/naga 在这些群中才可见;绑定和群聊投递也仅限这些群。 +# en: Naga allowlisted groups: /naga is only visible here, and binding/group delivery are restricted to these groups. allowed_groups = [] diff --git a/docs/configuration.md b/docs/configuration.md index dd41f87c..ff7ed8be 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -125,7 +125,7 @@ model_name = "gpt-4o-mini" | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| -| `ws_url` | `"ws://127.0.0.1:3001"` | OneBot WebSocket 地址 | 严格模式必填 | +| `ws_url` | `""` | OneBot WebSocket 地址 | 模板示例通常写 `ws://127.0.0.1:3001`;严格模式必填 | | `token` | `""` | OneBot token | 同时用于 URL 参数与 `Authorization` 头 | `onebot.*` 变更需要重启进程才能生效。 @@ -215,7 +215,28 @@ model_name = "gpt-4o-mini" - 若 `api_url/api_key/model_name` 任一缺失,会自动回退为 chat 模型(并告警)。 - 回退时会继承 chat 的 `api_mode`、`reasoning_*`、`responses_tool_choice_compat`、`responses_force_stateless_replay` 与 `request_params`;旧 `thinking_*` 仍保持安全模型自身默认值。 -### 4.4.5 `[models.agent]` Agent 执行模型 +### 4.4.5 `[models.naga]` Naga 审核模型 + +用途: +- 仅用于 `POST /api/v1/naga/messages/send` 前的消息审核。 + +默认: +- `max_tokens=160` +- `queue_interval_seconds=1.0` +- `api_mode="chat_completions"` +- `reasoning_enabled=false` +- `reasoning_effort="medium"` +- `thinking_enabled=false` +- `thinking_budget_tokens=0` +- `thinking_include_budget=true` +- `thinking_tool_call_compat=true` +- `responses_tool_choice_compat=false` +- `responses_force_stateless_replay=false` + +关键回退逻辑: +- 若整个节缺失或 `api_url/api_key/model_name` 任一缺失:完整回退到 `models.security`,并沿用安全模型的请求参数。 + +### 4.4.6 `[models.agent]` Agent 执行模型 默认: - `max_tokens=4096` @@ -227,14 +248,14 @@ model_name = "gpt-4o-mini" - `responses_tool_choice_compat=false` - `responses_force_stateless_replay=false` -### 4.4.6 `[models.historian]` 史官模型 +### 4.4.7 `[models.historian]` 史官模型 - 用于认知记忆后台改写。 - 若整个节缺失或为空:完整回退到 `models.agent`。 - 若部分字段缺失:逐项继承 agent 配置,包括 `api_mode`、`reasoning_*`、`thinking_*`、`responses_tool_choice_compat`、`responses_force_stateless_replay` 与 `request_params`。 - `queue_interval_seconds<=0` 时回退到 agent 的间隔。 -### 4.4.7 模型池 +### 4.4.8 模型池 相关节: - `[models.chat.pool]` @@ -266,7 +287,7 @@ model_name = "gpt-4o-mini" 2. 对应池 `enabled=true` 3. 池列表非空 -### 4.4.8 `[models.embedding]` 嵌入模型 +### 4.4.9 `[models.embedding]` 嵌入模型 | 字段 | 默认值 | 说明 | |---|---:|---| @@ -279,7 +300,7 @@ model_name = "gpt-4o-mini" | `document_instruction` | `""` | 文档前缀 | | `request_params` | `{}` | 额外请求体参数;保留字段如 `model`/`input`/`dimensions` 会忽略 | -### 4.4.9 `[models.rerank]` 重排模型 +### 4.4.10 `[models.rerank]` 重排模型 | 字段 | 默认值 | 说明 | |---|---:|---| @@ -578,8 +599,6 @@ model_name = "gpt-4o-mini" | `enabled` | `true` | 开启认知记忆 | | `bot_name` | `Undefined` | 史官改写中使用的 bot 名称 | -说明:当前版本解析器尚未从 `config.toml` 显式读取 `cognitive.bot_name`,运行时会保持默认值 `Undefined`。 - ### 4.24.2 `[cognitive.vector_store]` | 字段 | 默认值 | 说明 | @@ -639,7 +658,7 @@ model_name = "gpt-4o-mini" > **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** -启用后允许 NagaAgent 通过绑定审批机制向 QQ 群/用户发送回调消息。鉴权采用双层模型:共享密钥 `api_key` 验证服务器身份 + 每个绑定独立的 scoped token 验证调用权限。 +启用后允许 NagaAgent 通过绑定审批机制向 QQ 群/用户发送回调消息。共享密钥统一使用 `Authorization: Bearer {naga.api_key}`,其中 `messages/send` 与 `unbind` 还会额外校验 `bind_uuid + naga_id + delivery_signature`。 **开关分层**: @@ -648,14 +667,14 @@ model_name = "gpt-4o-mini" | `[features].nagaagent_mode_enabled` | 总开关:AI 侧行为(提示词切换、工具暴露) | `false` | | `[naga].enabled` | 子开关:外部网关集成(回调 API、`/naga` 命令、绑定管理) | `false` | -- 仅当两者均为 `true` 时,外部网关集成生效(API 端点注册、`/naga` 命令可用) +- 仅当 `[api].enabled = true`、`[features].nagaagent_mode_enabled = true`、`[naga].enabled = 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_url` | `""` | Naga 服务器 API 地址 | 为空时无法向远端提交 bind request / revoke 同步 | | `api_key` | `""` | Undefined ↔ Naga 共享密钥 | 回调端点通过 `Authorization: Bearer` 校验 | | `allowed_groups` | `[]` | Naga 服务群聊名单 | 绑定命令和回调群发仅限名单内的群 | @@ -664,7 +683,8 @@ model_name = "gpt-4o-mini" - 私聊场景不受 `allowed_groups` 限制 - 回调群发仅发到绑定时的群(该群须仍在 `allowed_groups` 内) - 回调私聊只需开关开启,不受 `allowed_groups` 限制 -- `/api/v1/naga/*` 端点仅在两个开关均开启时注册 +- `/api/v1/naga/*` 端点仅在 `api.enabled`、`nagaagent_mode_enabled`、`naga.enabled` 三者均开启时注册 +- Runtime API 关闭时,`/naga` 命令也不会在 `/help` 中显示 **数据存储**:绑定数据持久化在 `data/naga_bindings.json`,Unix 下自动 `chmod 600`。 @@ -724,7 +744,7 @@ model_name = "gpt-4o-mini" - `ONEBOT_WS_URL` / `ONEBOT_TOKEN` - `CHAT_MODEL_API_URL` / `CHAT_MODEL_API_KEY` / `CHAT_MODEL_NAME` - `CHAT_MODEL_API_MODE` / `CHAT_MODEL_REASONING_ENABLED` / `CHAT_MODEL_REASONING_EFFORT` / `CHAT_MODEL_RESPONSES_TOOL_CHOICE_COMPAT` / `CHAT_MODEL_RESPONSES_FORCE_STATELESS_REPLAY` -- `VISION_MODEL_*` / `AGENT_MODEL_*` / `SECURITY_MODEL_*` / `HISTORIAN_MODEL_*` +- `VISION_MODEL_*` / `AGENT_MODEL_*` / `SECURITY_MODEL_*` / `NAGA_MODEL_*` / `HISTORIAN_MODEL_*` - 上述模型环境变量同样覆盖 `*_THINKING_ENABLED`、`*_THINKING_BUDGET_TOKENS`、`*_THINKING_TOOL_CALL_COMPAT`、`*_RESPONSES_TOOL_CHOICE_COMPAT`、`*_RESPONSES_FORCE_STATELESS_REPLAY` - `EMBEDDING_MODEL_*` / `RERANK_MODEL_*` - `SEARXNG_URL` diff --git a/docs/naga_integration_contract.md b/docs/naga_integration_contract.md new file mode 100644 index 00000000..ca535ea9 --- /dev/null +++ b/docs/naga_integration_contract.md @@ -0,0 +1,250 @@ +# Undefined <-> NagaAgent Integration Contract + +本文档给 NagaAgent 开发组使用,描述 Undefined 当前会提供的 Runtime API、Undefined 会调用的 Naga 接口,以及双方约定的鉴权方式。 + +## Shared Auth + +- 所有双向 HTTP 请求都使用: + +```http +Authorization: Bearer +``` + +- `api_key` 是双方线下约定的共享密钥。 +- Undefined 侧所有 Naga 相关接口都直接挂在 Runtime API 下。 +- 这些端点只有在 `[api].enabled`、`[features].nagaagent_mode_enabled`、`[naga].enabled` 同时为 `true` 时才会注册。 + +## Undefined Provides + +### 1. Bind Callback + +`POST /api/v1/naga/bind/callback` + +用途: + +- Naga 端异步回调某个 `bind_uuid` 的最终结果 +- 只有回调 `approved` 后,Undefined 才会真正激活绑定 + +请求体: + +```json +{ + "bind_uuid": "unique-bind-uuid", + "naga_id": "alice", + "status": "approved", + "delivery_signature": "opaque-signature-from-naga", + "reason": "" +} +``` + +字段说明: + +- `bind_uuid`: Undefined 发起绑定时生成的唯一请求号 +- `naga_id`: 用户绑定的远端身份 +- `status`: `approved` 或 `rejected` +- `delivery_signature`: 当 `status=approved` 时必填,后续所有消息发送/解绑都要带这个签名 +- `reason`: 当 `status=rejected` 时可选,Undefined 会作为拒绝原因提示给用户 + +成功响应: + +```json +{ + "ok": true, + "status": "approved", + "idempotent": false, + "naga_id": "alice", + "bind_uuid": "unique-bind-uuid" +} +``` + +### 2. Message Send + +`POST /api/v1/naga/messages/send` + +用途: + +- Naga 端主动让 Undefined 向 QQ 发送文本或渲染消息 + +请求体: + +```json +{ + "bind_uuid": "unique-bind-uuid", + "naga_id": "alice", + "delivery_signature": "opaque-signature-from-naga", + "target": { + "qq_id": 123456, + "group_id": 654321, + "mode": "both" + }, + "message": { + "format": "markdown", + "content": "# hello" + } +} +``` + +字段说明: + +- `bind_uuid` + `naga_id` + `delivery_signature`:三者一起校验绑定身份 +- `target.qq_id` / `target.group_id`: 目标参数必须显式提供,但只允许等于已绑定 QQ / 已绑定群 +- `target.mode`: `private` / `group` / `both` +- `message.format`: `text` / `markdown` / `html` +- `message.content`: 实际消息内容 + +发送规则: + +- Undefined 只允许投递到“绑定 QQ + 绑定群” +- 若 `mode` 包含 `group`,绑定群必须仍在 `config.[naga].allowed_groups` +- `markdown/html` 会按当前 Runtime API 的渲染逻辑先尝试转图片 +- 若渲染失败,会回退为文本发送,并在响应中标记 `render_fallback=true` +- 当 `mode=both` 时,只要私聊或群聊至少有一个发送成功,接口仍返回 `200`;由 `sent_private` / `sent_group` 表示实际投递结果 +- 成功响应会额外带 `partial_success` 与 `delivery_status`,用于显式区分“完全成功”和“部分成功” + +审核规则: + +- 发送前会做一次 AI 审核 +- 若 `format=markdown/html`,不仅检查渲染后的语义,还会检查“未渲染直接发送时”的风险 +- 明确命中以下风险时会拒发: + - `pornography` + - `politics_illegal` + - `personal_privacy` +- 审核模型异常/超时时不拦截,但响应会返回 `moderation.status=error_allowed` + +成功响应示例: + +```json +{ + "ok": true, + "naga_id": "alice", + "bind_uuid": "unique-bind-uuid", + "sent_private": true, + "sent_group": true, + "partial_success": false, + "delivery_status": "full_success", + "rendered": true, + "render_fallback": false, + "moderation": { + "status": "passed", + "blocked": false, + "categories": [], + "message": "ok", + "model_name": "naga-moderation" + } +} +``` + +拦截响应示例: + +```json +{ + "ok": false, + "error": "message blocked by moderation", + "moderation": { + "status": "blocked", + "blocked": true, + "categories": ["personal_privacy"], + "message": "contains privacy leak", + "model_name": "naga-moderation" + } +} +``` + +### 3. Unbind + +`POST /api/v1/naga/unbind` + +用途: + +- Naga 端主动要求 Undefined 吊销某个绑定 + +请求体: + +```json +{ + "bind_uuid": "unique-bind-uuid", + "naga_id": "alice", + "delivery_signature": "opaque-signature-from-naga" +} +``` + +说明: + +- 需要同时校验 `api_key`、`bind_uuid`、`naga_id`、`delivery_signature` +- 成功后该绑定立即失效 + +## Undefined Calls Naga + +### 1. Bind Request + +Undefined 在用户执行 `/naga bind ` 后,会调用: + +`POST {config.[naga].api_url}/api/integration/bind/request` + +请求体: + +```json +{ + "bind_uuid": "unique-bind-uuid", + "naga_id": "alice", + "request_context": { + "naga_id": "alice", + "sender_id": 123456, + "group_id": 654321, + "scope": "group", + "bot_qq": 42, + "group_name": "group-654321", + "sender_nickname": "user-123456" + } +} +``` + +说明: + +- `bind_uuid` 是整个绑定流程的唯一关联键 +- `request_context` 是 Undefined 能拿到的上下文摘要,字段可能按运行时情况增减 +- Naga 端收到请求后,应在完成验证后调用 `POST /api/v1/naga/bind/callback` + +### 2. Bind Revoke + +Undefined 在本地 `/naga unbind` 成功后,会 best-effort 调用: + +`POST {config.[naga].api_url}/api/integration/bind/revoke` + +请求体: + +```json +{ + "bind_uuid": "unique-bind-uuid", + "naga_id": "alice" +} +``` + +说明: + +- 这是同步吊销通知 +- 即便这一步失败,Undefined 本地绑定也已经失效 + +## Error Handling + +- `401 Unauthorized`: `Authorization` 缺失或 `api_key` 错误 +- `POST /api/v1/naga/bind/callback` + - `403`: `bind_uuid` / `delivery_signature` 不匹配 + - `404`: `pending` 不存在 + - `409`: 状态冲突,例如重复激活不同签名、请求已被其他结果终结 +- `POST /api/v1/naga/messages/send` + - `403`: 绑定不存在或已吊销、签名不匹配、目标不匹配、群不在白名单、审核拦截 + - `502`: 所有目标投递失败 +- `POST /api/v1/naga/unbind` + - `403`: `delivery_signature` 不匹配 + - `404`: 绑定不存在 + - `409`: 绑定代际不一致或其它状态冲突 +- `502`: 目标投递失败 + +## Lifecycle Summary + +1. 用户在白名单群执行 `/naga bind ` +2. Undefined 生成 `bind_uuid` 并调用 Naga `bind/request` +3. Naga 完成验证后回调 Undefined `bind/callback` +4. 绑定生效后,Naga 可调用 `messages/send` +5. 任一方触发解绑后,Undefined 本地立刻吊销,并向对端同步 diff --git a/docs/openapi.md b/docs/openapi.md index fd28e6be..4546759e 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -59,7 +59,7 @@ tool_invoke_callback_timeout = 10 ## 2. 鉴权规则 -- 所有 `/api/*` 路由都要求请求头: +- 除 `/api/v1/naga/*` 外,所有 `/api/*` 路由都要求请求头: ```http X-Undefined-API-Key: @@ -145,7 +145,7 @@ curl http://127.0.0.1:8788/openapi.json | 字段 | 类型 | 说明 | |---|---|---| -| `ok` | `bool` | 全部端点是否正常 | +| `ok` | `bool` | 是否所有探针结果都返回 `status=ok` | | `timestamp` | `string` | ISO 时间戳 | | `results` | `array` | 各端点检测结果列表 | @@ -160,19 +160,28 @@ curl http://127.0.0.1:8788/openapi.json | `http_status` | `int` | HTTP 响应状态码(仅 `ok` 时) | | `latency_ms` | `float` | 响应延迟(毫秒,仅 `ok` 时) | | `error` | `string` | 错误信息(仅 `error` 时) | +| `reason` | `string` | 跳过原因(仅 `skipped` 时,例如 `empty_url`) | | `host` / `port` | `string` / `int` | WebSocket 端点的主机与端口 | +说明:这里的 HTTP 探针是“可达性/连通性探测”。只要拿到 HTTP 响应就会记为 `status=ok`,真实业务状态应结合 `http_status` 一起解读。 +当 Naga 集成未启用时,`naga_model` 会以 `status=skipped`、`reason=naga_integration_disabled` 出现在结果中。 + ### 记忆(只读) - `GET /api/v1/memory` -- 查询参数:`q`(可选,关键字过滤) +- 查询参数: + - `q`:关键字过滤(可选) + - `top_k`:返回条数上限(可选,正整数) + - `time_from` / `time_to`:ISO 时间范围过滤(可选) 说明:仅提供查看/查询,不提供写入接口,不改变现有记忆存储格式。 ### 认知记忆检索 / 侧写 - `GET /api/v1/cognitive/events?q=...` + - 额外支持:`target_user_id`、`target_group_id`、`sender_id`、`request_type`、`top_k`、`time_from`、`time_to` - `GET /api/v1/cognitive/profiles?q=...` + - 额外支持:`entity_type`、`top_k` - `GET /api/v1/cognitive/profile/{entity_type}/{entity_id}` 说明:这些接口仅在 `cognitive.enabled = true` 时可用,否则返回错误。 @@ -356,6 +365,7 @@ curl http://127.0.0.1:8788/openapi.json #### 回调 URL 要求 - 支持 HTTP 和 HTTPS(scheme 必须为 `http://` 或 `https://`,不接受 `ftp://` 等其他协议)。 +- 直接使用私网/回环/链路本地 IP 字面量会被拒绝(如 `127.0.0.1`、`10.x`、`192.168.x`、`::1`);域名形式可通过校验。 - 回调超时由 `tool_invoke_callback_timeout` 独立控制。 - 回调失败不影响工具调用的执行结果,仅记录日志 `[ToolInvoke] 回调失败`。 @@ -433,114 +443,50 @@ WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 Header - 检查回调 URL 是否可达、证书是否有效。 - 可通过 `tool_invoke_callback_timeout` 调整超时。 -## 8. Naga 回调 API - -Naga 集成端点仅在 `[features].nagaagent_mode_enabled = true` 且 `[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` 格式会渲染为图片发送 - -响应: +## 8. Naga 集成端点 -```json -{ - "ok": true, - "sent_private": true, - "sent_group": true -} -``` +Naga 集成端点仅在以下条件同时满足时注册: -### GET /api/v1/naga/targets — 查询发送目标 +- `[api].enabled = true` +- `[features].nagaagent_mode_enabled = true` +- `[naga].enabled = true` -查询某个 naga_id 绑定的 QQ 用户和可用群。 +这些端点**不走主 API Key 中间件**,统一使用 `Authorization: Bearer {config.[naga].api_key}` 鉴权。 -请求: +### 当前端点 -```http -GET /api/v1/naga/targets?naga_id=alice -Authorization: Bearer {api_key} -X-Naga-Token: udf_xxx -``` +| 路径 | 作用 | +|------|------| +| `POST /api/v1/naga/bind/callback` | Naga 异步确认某个 `bind_uuid` 的绑定结果 | +| `POST /api/v1/naga/messages/send` | Naga 验签后向“绑定 QQ + 绑定群”发送消息 | +| `POST /api/v1/naga/unbind` | Naga 主动吊销已有绑定 | -响应: +### 协议说明 -```json -{ - "naga_id": "alice", - "bound_qq": 123456, - "groups": [ - { "group_id": 789, "group_name": "测试群" } - ] -} -``` +- 绑定流程使用 `bind_uuid` 驱动,而不是早期的 scoped token。 +- 发送流程使用 `bind_uuid + naga_id + delivery_signature` 三元组验签。 +- `target.qq_id` / `target.group_id` 必须显式提供,并且必须等于已绑定目标。 +- `target.mode` 支持 `private` / `group` / `both`。 +- `message.format` 支持 `text` / `markdown` / `html`。 +- 发送前会进行一次审核;命中风险时返回 `403`,审核模型异常/超时时 fail-open,并在响应中返回 `moderation.status=error_allowed`。 +- `markdown` / `html` 会优先尝试渲染成图片;渲染失败时会回退为文本发送,并在响应中返回 `render_fallback=true`。 +- 当 `mode=both` 时,只要私聊或群聊至少有一个成功,接口仍返回 `200`,由 `sent_private` / `sent_group` 指示实际投递结果。 +- 成功响应会额外返回 `partial_success` 与 `delivery_status`:完全成功时为 `false` / `full_success`,部分成功时为 `true` / `partial_success`。 -### 错误状态码 +### 典型错误码 | 状态码 | 含义 | |--------|------| -| `400` | 请求体格式错误(缺少必填字段、format 不合法等) | -| `401` | 共享密钥校验失败 | -| `403` | scoped token 不匹配、绑定已吊销、或群不在白名单 | +| `400` | 请求体格式错误、缺少必填字段 | +| `401` | `Authorization` 缺失或共享密钥错误 | +| `403` | `bind_uuid` / `delivery_signature` / target 不匹配,群不在白名单,或审核拦截 | +| `404` | `bind/callback` 所需的 pending 不存在 | +| `409` | 状态冲突,例如绑定代际不一致、请求已被其他结果终结 | +| `502` | 目标投递全部失败 | | `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" -``` +- [docs/naga_integration_contract.md](/data0/Undefined/docs/naga_integration_contract.md) diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 921ea941..5607a707 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -176,44 +176,32 @@ Undefined 提供了一套强大的斜杠指令(Slash Commands)系统。管 #### 6. Naga 集成管理 -> **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** 需要在 `config.toml` 中同时启用 `[features].nagaagent_mode_enabled` 和 `[naga].enabled`。 +> **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** 需要在 `config.toml` 中同时启用 `[api].enabled`、`[features].nagaagent_mode_enabled` 和 `[naga].enabled`。 - **/naga \<子命令\> [参数]** - - **说明**:NagaAgent 绑定管理。通过子命令完成绑定申请、审批、吊销和查询。 - - **前置条件**:`features.nagaagent_mode_enabled = true` 且 `naga.enabled = true`。 + - **说明**:NagaAgent 绑定管理。当前只保留发起绑定和解绑两个子命令。 + - **前置条件**:`api.enabled = true`、`features.nagaagent_mode_enabled = true` 且 `naga.enabled = true`。 **子命令列表**: | 子命令 | 权限 | 作用域 | 说明 | |--------|------|--------|------| - | `bind ` | 公开 | 仅群聊 | 在当前群提交绑定申请,记录 QQ 号和群号 | - | `approve ` | 超管 | 群聊/私聊 | 通过绑定申请,生成 scoped token 并同步到 Naga | - | `reject ` | 超管 | 群聊/私聊 | 拒绝绑定申请 | - | `revoke ` | 超管 | 群聊/私聊 | 吊销已有绑定并通知 Naga 删除 token | - | `list` | 超管 | 群聊/私聊 | 列出所有活跃绑定 | - | `pending` | 超管 | 群聊/私聊 | 列出待审核申请 | - | `info ` | 超管 | 群聊/私聊 | 查看绑定详情(token 脱敏显示) | + | `bind ` | 公开 | 仅群聊 | 在当前白名单群提交绑定申请,生成 `bind_uuid` 并发送到 Naga 端等待回调确认 | + | `unbind ` | 超管 | 群聊/私聊 | 吊销当前绑定,并 best-effort 通知远端同步解绑 | **权限模型**:命令入口 `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 ← 超管查看详情 + /naga bind alice ← 群内普通用户发起绑定 + /naga unbind alice ← 超管解绑(私聊或白名单群均可) ``` - **额外行为**: - 群聊场景下,所有子命令仅在 `naga.allowed_groups` 白名单内的群可用,非白名单群静默忽略。 - - 私聊场景下不受 `allowed_groups` 限制。 - - `approve` 成功后会自动调 Naga API 同步 token 并私聊通知申请人。 - - `reject` 成功后私聊通知申请人。 - - `revoke` 成功后调 Naga API 删除 token。 - - `bind` 提交后自动私聊通知超管。 + - 私聊场景下不受 `allowed_groups` 限制,但仍要求当前进程的 Runtime API 已启用。 + - `bind` 的成功提示只代表“本地已记录并尝试提交到 Naga 端”,真正生效要等 `bind/callback` 回调确认。 + - `unbind` 成功后会私聊通知绑定用户,并尝试调用远端 `bind/revoke` 接口同步状态。 --- @@ -334,10 +322,7 @@ async def execute(args: list[str], context: CommandContext) -> None: ```json { "bind": "group_only", - "approve": "superadmin", - "reject": "superadmin", - "list": "superadmin", - "info": "superadmin" + "unbind": "superadmin" } ``` diff --git a/pyproject.toml b/pyproject.toml index 8a1d4afa..a3716d36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.2.3" +version = "3.2.4" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ diff --git a/res/prompts/naga_message_moderation.txt b/res/prompts/naga_message_moderation.txt new file mode 100644 index 00000000..e980274b --- /dev/null +++ b/res/prompts/naga_message_moderation.txt @@ -0,0 +1,26 @@ +你是 Undefined 的 Naga 外发消息审核器。 + +任务: +1. 审核 Naga 准备发送到 QQ 的内容是否存在以下明确风险: + - pornography:涉黄、淫秽、性交易、未成年人性暗示等 + - politics_illegal:违法涉政、煽动违法、危害国家安全、鼓动暴力或颠覆等 + - personal_privacy:明确泄露个人隐私,如手机号、住址、身份证号、银行卡号、真实姓名+敏感联系方式等 +2. 输入里同时提供了: + - raw:原始内容 + - plain_text:去渲染/去标签后的纯文本视图 + 你必须同时判断这两种视图下的风险,尤其要检查“未渲染直接发送时”的安全风险。 +3. 只有在风险明确成立时才输出 block。无法确认、语义正常、只是普通讨论时输出 allow。 + +输出要求: +- 必须调用提供的函数工具 `submit_naga_moderation_result` +- 不要把 JSON 写在正文里 +- 不要输出 Markdown,不要解释,不要加代码块 +- 通过函数参数提交: + - `decision`: `allow` 或 `block` + - `categories`: 风险类别数组 + - `reason`: 简短中文原因 + +规则: +- 若 `decision=allow`,则 `categories` 为空数组。 +- 若 `decision=block`,则 `categories` 里至少包含一个上面的枚举值。 +- 不要发明新字段,不要输出额外文本。 diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index 4d5af08a..2f8d8910 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -1,3 +1,3 @@ """Undefined - A high-performance, highly scalable QQ group and private chat robot based on a self-developed architecture.""" -__version__ = "3.2.3" +__version__ = "3.2.4" diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index ede39a3c..b4d7e202 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -323,6 +323,15 @@ async def _probe_http_endpoint( } +async def _skipped_probe( + *, name: str, reason: str, model_name: str = "" +) -> dict[str, Any]: + payload: dict[str, Any] = {"name": name, "status": "skipped", "reason": reason} + if model_name: + payload["model_name"] = model_name + return payload + + async def _probe_ws_endpoint(url: str, timeout_seconds: float = 5.0) -> dict[str, Any]: normalized = str(url or "").strip() if not normalized: @@ -366,6 +375,128 @@ async def _probe_ws_endpoint(url: str, timeout_seconds: float = 5.0) -> dict[str def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[str, Any]: server_url = f"{request.scheme}://{request.host}" + cfg = ctx.config_getter() + naga_cfg = getattr(cfg, "naga", None) + naga_routes_enabled = ( + bool(getattr(cfg, "nagaagent_mode_enabled", False)) + and bool(getattr(naga_cfg, "enabled", False)) + and ctx.naga_store is not None + ) + paths: dict[str, Any] = { + "/health": { + "get": { + "summary": "Health check", + "security": [], + "responses": {"200": {"description": "OK"}}, + } + }, + "/openapi.json": { + "get": { + "summary": "OpenAPI schema", + "security": [], + "responses": {"200": {"description": "Schema JSON"}}, + } + }, + "/api/v1/probes/internal": { + "get": { + "summary": "Internal runtime probes", + "description": ( + "Returns system info (version, Python, platform, uptime), " + "OneBot connection status, request queue snapshot, " + "memory count, cognitive service status, API config, " + "skill statistics (tools/agents/anthropic_skills with call counts), " + "and model configuration (names, masked URLs, thinking flags)." + ), + } + }, + "/api/v1/probes/external": { + "get": { + "summary": "External dependency probes", + "description": ( + "Concurrently probes all configured model API endpoints " + "(chat, vision, security, naga, agent, embedding, rerank) " + "and OneBot WebSocket. Each result includes status, " + "model name, masked URL, HTTP status code, and latency." + ), + } + }, + "/api/v1/memory": {"get": {"summary": "List/search manual memories"}}, + "/api/v1/cognitive/events": { + "get": {"summary": "Search cognitive event memories"} + }, + "/api/v1/cognitive/profiles": {"get": {"summary": "Search cognitive profiles"}}, + "/api/v1/cognitive/profile/{entity_type}/{entity_id}": { + "get": {"summary": "Get a profile by entity type/id"} + }, + "/api/v1/chat": { + "post": { + "summary": "WebUI special private chat", + "description": ( + "POST JSON {message, stream?}. " + "When stream=true, response is SSE with keep-alive comments." + ), + } + }, + "/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." + ), + } + }, + } + + if naga_routes_enabled: + paths.update( + { + "/api/v1/naga/bind/callback": { + "post": { + "summary": "Finalize a Naga bind request", + "description": ( + "Internal callback used by Naga to approve or reject " + "a bind_uuid." + ), + "security": [{"BearerAuth": []}], + } + }, + "/api/v1/naga/messages/send": { + "post": { + "summary": "Send a Naga-authenticated message", + "description": ( + "Validates bind_uuid + delivery_signature, runs " + "moderation, then delivers the message." + ), + "security": [{"BearerAuth": []}], + } + }, + "/api/v1/naga/unbind": { + "post": { + "summary": "Revoke an active Naga binding", + "description": ( + "Allows Naga to proactively revoke a binding using " + "Authorization: Bearer ." + ), + "security": [{"BearerAuth": []}], + } + }, + } + ) + return { "openapi": "3.0.3", "info": { @@ -385,90 +516,12 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st "type": "apiKey", "in": "header", "name": _AUTH_HEADER, - } + }, + "BearerAuth": {"type": "http", "scheme": "bearer"}, } }, "security": [{"ApiKeyAuth": []}], - "paths": { - "/health": { - "get": { - "summary": "Health check", - "security": [], - "responses": {"200": {"description": "OK"}}, - } - }, - "/openapi.json": { - "get": { - "summary": "OpenAPI schema", - "security": [], - "responses": {"200": {"description": "Schema JSON"}}, - } - }, - "/api/v1/probes/internal": { - "get": { - "summary": "Internal runtime probes", - "description": ( - "Returns system info (version, Python, platform, uptime), " - "OneBot connection status, request queue snapshot, " - "memory count, cognitive service status, API config, " - "skill statistics (tools/agents/anthropic_skills with call counts), " - "and model configuration (names, masked URLs, thinking flags)." - ), - } - }, - "/api/v1/probes/external": { - "get": { - "summary": "External dependency probes", - "description": ( - "Concurrently probes all configured model API endpoints " - "(chat, vision, security, agent, embedding, rerank) " - "and OneBot WebSocket. Each result includes status, " - "model name, masked URL, HTTP status code, and latency." - ), - } - }, - "/api/v1/memory": {"get": {"summary": "List/search manual memories"}}, - "/api/v1/cognitive/events": { - "get": {"summary": "Search cognitive event memories"} - }, - "/api/v1/cognitive/profiles": { - "get": {"summary": "Search cognitive profiles"} - }, - "/api/v1/cognitive/profile/{entity_type}/{entity_id}": { - "get": {"summary": "Get a profile by entity type/id"} - }, - "/api/v1/chat": { - "post": { - "summary": "WebUI special private chat", - "description": ( - "POST JSON {message, stream?}. " - "When stream=true, response is SSE with keep-alive comments." - ), - } - }, - "/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." - ), - } - }, - }, + "paths": paths, } @@ -575,8 +628,15 @@ async def _auth_middleware( ): app.add_routes( [ - web.post("/api/v1/naga/callback", self._naga_callback_handler), - web.get("/api/v1/naga/targets", self._naga_targets_handler), + web.post( + "/api/v1/naga/bind/callback", + self._naga_bind_callback_handler, + ), + web.post( + "/api/v1/naga/messages/send", + self._naga_messages_send_handler, + ), + web.post("/api/v1/naga/unbind", self._naga_unbind_handler), ] ) logger.info("[RuntimeAPI] Naga 端点已注册") @@ -630,7 +690,13 @@ async def _internal_probe_handler(self, request: web.Request) -> Response: # 模型配置(脱敏) models_info: dict[str, Any] = {} - for label in ("chat_model", "vision_model", "agent_model", "security_model"): + for label in ( + "chat_model", + "vision_model", + "agent_model", + "security_model", + "naga_model", + ): mcfg = getattr(cfg, label, None) if mcfg is not None: models_info[label] = { @@ -690,6 +756,20 @@ async def _internal_probe_handler(self, request: web.Request) -> Response: async def _external_probe_handler(self, request: web.Request) -> Response: _ = request cfg = self._ctx.config_getter() + naga_probe = ( + _probe_http_endpoint( + name="naga_model", + base_url=cfg.naga_model.api_url, + api_key=cfg.naga_model.api_key, + model_name=cfg.naga_model.model_name, + ) + if bool(cfg.api.enabled and cfg.nagaagent_mode_enabled and cfg.naga.enabled) + else _skipped_probe( + name="naga_model", + reason="naga_integration_disabled", + model_name=cfg.naga_model.model_name, + ) + ) checks = [ _probe_http_endpoint( name="chat_model", @@ -709,6 +789,7 @@ async def _external_probe_handler(self, request: web.Request) -> Response: api_key=cfg.security_model.api_key, model_name=cfg.security_model.model_name, ), + naga_probe, _probe_http_endpoint( name="agent_model", base_url=cfg.agent_model.api_url, @@ -1513,7 +1594,7 @@ async def _execute_and_callback( ) # ------------------------------------------------------------------ - # Naga Callback / Targets API + # Naga Bind / Send / Unbind API # ------------------------------------------------------------------ def _verify_naga_api_key(self, request: web.Request) -> str | None: @@ -1532,205 +1613,438 @@ def _verify_naga_api_key(self, request: web.Request) -> str | None: return "invalid api_key" return None - async def _naga_callback_handler(self, request: web.Request) -> Response: - """POST /api/v1/naga/callback — Naga 消息回调""" + async def _naga_bind_callback_handler(self, request: web.Request) -> Response: + """POST /api/v1/naga/bind/callback — Naga 绑定回调。""" + auth_err = self._verify_naga_api_key(request) + if auth_err is not None: + logger.warning("[NagaBindCallback] 鉴权失败: %s", auth_err) + return _json_error("Unauthorized", status=401) + + try: + body = await request.json() + except Exception: + return _json_error("Invalid JSON", status=400) + + bind_uuid = str(body.get("bind_uuid", "") or "").strip() + naga_id = str(body.get("naga_id", "") or "").strip() + status = str(body.get("status", "") or "").strip().lower() + delivery_signature = str(body.get("delivery_signature", "") or "").strip() + reason = str(body.get("reason", "") or "").strip() + if not bind_uuid or not naga_id: + return _json_error("bind_uuid and naga_id are required", status=400) + if status not in {"approved", "rejected"}: + return _json_error("status must be 'approved' or 'rejected'", status=400) + + naga_store = self._ctx.naga_store + if naga_store is None: + return _json_error("Naga integration not available", status=503) + + sender = self._ctx.sender + if status == "approved": + if not delivery_signature: + return _json_error( + "delivery_signature is required when approved", status=400 + ) + binding, created, err = await naga_store.activate_binding( + bind_uuid=bind_uuid, + naga_id=naga_id, + delivery_signature=delivery_signature, + ) + if err: + logger.warning( + "[NagaBindCallback] 激活失败: naga_id=%s bind_uuid=%s err=%s", + naga_id, + bind_uuid, + err.message, + ) + return _json_error(err.message, status=err.http_status) + if created and binding is not None and sender is not None: + try: + await sender.send_private_message( + binding.qq_id, + f"🎉 你的 Naga 绑定已生效\nnaga_id: {naga_id}", + ) + except Exception as exc: + logger.warning("[NagaBindCallback] 通知绑定成功失败: %s", exc) + return web.json_response( + { + "ok": True, + "status": "approved", + "idempotent": not created, + "naga_id": naga_id, + "bind_uuid": bind_uuid, + } + ) + + pending, removed, err = await naga_store.reject_binding( + bind_uuid=bind_uuid, + naga_id=naga_id, + reason=reason, + ) + if err: + logger.warning( + "[NagaBindCallback] 拒绝失败: naga_id=%s bind_uuid=%s err=%s", + naga_id, + bind_uuid, + err.message, + ) + return _json_error(err.message, status=err.http_status) + if removed and pending is not None and sender is not None: + try: + detail = f"\n原因: {reason}" if reason else "" + await sender.send_private_message( + pending.qq_id, + f"❌ 你的 Naga 绑定被远端拒绝\nnaga_id: {naga_id}{detail}", + ) + except Exception as exc: + logger.warning("[NagaBindCallback] 通知绑定拒绝失败: %s", exc) + return web.json_response( + { + "ok": True, + "status": "rejected", + "idempotent": not removed, + "naga_id": naga_id, + "bind_uuid": bind_uuid, + } + ) + + async def _naga_messages_send_handler(self, request: web.Request) -> Response: + """POST /api/v1/naga/messages/send — 验签后发送消息。""" 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) + logger.warning("[NagaSend] 鉴权失败: %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) + bind_uuid = str(body.get("bind_uuid", "") or "").strip() naga_id = str(body.get("naga_id", "") or "").strip() - token = str(body.get("token", "") or "").strip() + delivery_signature = str(body.get("delivery_signature", "") or "").strip() + target = body.get("target") message = body.get("message") - - if not naga_id or not token: - return _json_error("naga_id and token are required", status=400) + if not bind_uuid or not naga_id or not delivery_signature: + return _json_error( + "bind_uuid, naga_id and delivery_signature are required", + status=400, + ) + if not isinstance(target, dict): + return _json_error("target object is required", status=400) if not isinstance(message, dict): return _json_error("message object is required", status=400) + raw_target_qq = target.get("qq_id") + raw_target_group = target.get("group_id") + if raw_target_qq is None or raw_target_group is None: + return _json_error( + "target.qq_id and target.group_id are required", status=400 + ) + try: + target_qq = int(raw_target_qq) + target_group = int(raw_target_group) + except Exception: + return _json_error( + "target.qq_id and target.group_id must be integers", status=400 + ) + mode = str(target.get("mode", "") or "").strip().lower() + if mode not in {"private", "group", "both"}: + return _json_error( + "target.mode must be 'private', 'group', or 'both'", 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"): + if fmt not in {"text", "markdown", "html"}: return _json_error( "message.format must be 'text', 'markdown', or 'html'", status=400 ) + if not content: + return _json_error("message.content is 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: + binding, err_msg = await naga_store.acquire_delivery( + naga_id=naga_id, + bind_uuid=bind_uuid, + delivery_signature=delivery_signature, + ) + if binding is None: logger.warning( - "[NagaCallback] token 校验失败: naga_id=%s reason=%s token=%s", + "[NagaSend] 签名校验失败: naga_id=%s bind_uuid=%s reason=%s signature=%s", naga_id, - err_msg, - mask_token(token), + bind_uuid, + err_msg.message if err_msg is not None else "unknown_error", + mask_token(delivery_signature), ) - return _json_error(err_msg, status=403) + return _json_error( + err_msg.message if err_msg is not None else "delivery not available", + status=err_msg.http_status if err_msg is not None else 403, + ) + try: + if target_qq != binding.qq_id or target_group != binding.group_id: + return _json_error("target does not match bound qq/group", 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() + if mode == "group" and binding.group_id not in cfg.naga.allowed_groups: + return _json_error( + "bound group is not in naga.allowed_groups", 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 - tmp_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 + sender = self._ctx.sender + if sender is None: + return _json_error("sender not available", status=503) + + moderation = None + security = getattr(self._ctx.command_dispatcher, "security", None) + if security is None or not hasattr(security, "moderate_naga_message"): + moderation = { + "status": "error_allowed", + "blocked": False, + "categories": [], + "message": "Naga moderation service unavailable; message sent without moderation block", + "model_name": "", + } + else: + result = await security.moderate_naga_message( + message_format=fmt, + content=content, + ) + moderation = { + "status": result.status, + "blocked": result.blocked, + "categories": result.categories, + "message": result.message, + "model_name": result.model_name, + } + if moderation["blocked"]: + return web.json_response( + { + "ok": False, + "error": "message blocked by moderation", + "moderation": moderation, + }, + status=403, + ) - # 6. 发送消息 - sent_private = False - sent_group = False + send_content: str | None = content if fmt == "text" else None + image_path: str | None = None + tmp_path: str | None = None + rendered = False + render_fallback = False + if fmt in {"markdown", "html"}: + import tempfile - 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}]" + try: + html_str = content + if fmt == "markdown": + html_str = await render_markdown_to_html(content) + fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="naga_send_") + os.close(fd) + await render_html_to_image(html_str, tmp_path) + image_path = tmp_path + rendered = True + except Exception as exc: + logger.warning("[NagaSend] 渲染失败,回退文本发送: %s", exc) + send_content = content + render_fallback = True + + sent_private = False + sent_group = False + group_policy_blocked = False + + async def _ensure_delivery_active() -> tuple[Any, Response | None]: + current_binding, live_err = await naga_store.ensure_delivery_active( + naga_id=naga_id, + bind_uuid=bind_uuid, + ) + if current_binding is None: + return None, web.json_response( + { + "ok": False, + "error": ( + live_err.message + if live_err is not None + else "delivery no longer active" + ), + "sent_private": sent_private, + "sent_group": sent_group, + "moderation": moderation, + }, + status=live_err.http_status if live_err is not None else 409, + ) + return current_binding, None - # 私聊发给绑定的 QQ 用户 try: - if send_content is not None: - await sender.send_private_message(binding.qq_id, send_content) - elif cq_image is not None: - 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, + 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}]" + + if mode in {"private", "both"}: + current_binding, abort_response = await _ensure_delivery_active() + if abort_response is not None: + return abort_response + try: + if send_content is not None: + await sender.send_private_message( + current_binding.qq_id, send_content + ) + elif cq_image is not None: + await sender.send_private_message( + current_binding.qq_id, cq_image + ) + sent_private = True + except Exception as exc: + logger.warning( + "[NagaSend] 私聊发送失败: naga_id=%s qq=%d err=%s", + naga_id, + current_binding.qq_id, + exc, + ) + + if mode in {"group", "both"}: + current_binding, abort_response = await _ensure_delivery_active() + if abort_response is not None: + return abort_response + current_cfg = self._ctx.config_getter() + if current_binding.group_id not in current_cfg.naga.allowed_groups: + group_policy_blocked = True + else: + try: + if send_content is not None: + await sender.send_group_message( + current_binding.group_id, send_content + ) + elif cq_image is not None: + await sender.send_group_message( + current_binding.group_id, cq_image + ) + sent_group = True + except Exception as exc: + logger.warning( + "[NagaSend] 群聊发送失败: naga_id=%s group=%d err=%s", + naga_id, + current_binding.group_id, + exc, + ) + finally: + if tmp_path is not None: + try: + os.unlink(tmp_path) + except OSError: + pass + + if mode == "private" and not sent_private: + return web.json_response( + { + "ok": False, + "error": "private delivery failed", + "sent_private": sent_private, + "sent_group": sent_group, + "moderation": moderation, + }, + status=502, ) - - # 群聊发到绑定时的群(须仍在 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 cq_image is not None: - 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, + if mode == "group" and not sent_group: + return web.json_response( + { + "ok": False, + "error": "group delivery failed", + "sent_private": sent_private, + "sent_group": sent_group, + "moderation": moderation, + }, + status=502, + ) + if mode == "both" and not (sent_private or sent_group): + if group_policy_blocked: + return web.json_response( + { + "ok": False, + "error": "bound group is not in naga.allowed_groups", + "sent_private": sent_private, + "sent_group": sent_group, + "moderation": moderation, + }, + status=403, ) - else: - logger.info( - "[NagaCallback] 群 %d 不在 allowed_groups 中,跳过群发", - binding.group_id, + return web.json_response( + { + "ok": False, + "error": "all deliveries failed", + "sent_private": sent_private, + "sent_group": sent_group, + "moderation": moderation, + }, + status=502, ) - 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) - return web.json_response( - { - "ok": True, - "sent_private": sent_private, - "sent_group": sent_group, - } - ) + await naga_store.record_usage(naga_id, bind_uuid=bind_uuid) + partial_success = mode == "both" and (sent_private != sent_group) + return web.json_response( + { + "ok": True, + "naga_id": naga_id, + "bind_uuid": bind_uuid, + "sent_private": sent_private, + "sent_group": sent_group, + "partial_success": partial_success, + "delivery_status": ( + "partial_success" if partial_success else "full_success" + ), + "rendered": rendered, + "render_fallback": render_fallback, + "moderation": moderation, + } + ) + finally: + await naga_store.release_delivery(bind_uuid=bind_uuid) - async def _naga_targets_handler(self, request: web.Request) -> Response: - """GET /api/v1/naga/targets — 查询发送目标""" - # 1. 共享密钥校验 + async def _naga_unbind_handler(self, request: web.Request) -> Response: + """POST /api/v1/naga/unbind — 远端主动解绑。""" auth_err = self._verify_naga_api_key(request) if auth_err is not None: - logger.warning("[NagaTargets] 鉴权失败: %s", auth_err) + logger.warning("[NagaUnbind] 鉴权失败: %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", "") + try: + body = await request.json() + except Exception: + return _json_error("Invalid JSON", status=400) - if not naga_id or not token: - return _json_error("naga_id and X-Naga-Token are required", status=400) + bind_uuid = str(body.get("bind_uuid", "") or "").strip() + naga_id = str(body.get("naga_id", "") or "").strip() + delivery_signature = str(body.get("delivery_signature", "") or "").strip() + if not bind_uuid or not naga_id or not delivery_signature: + return _json_error( + "bind_uuid, naga_id and delivery_signature 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) + binding, changed, err = await naga_store.revoke_binding( + naga_id, + expected_bind_uuid=bind_uuid, + delivery_signature=delivery_signature, + ) 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 _json_error( + err.message if err is not None else "binding not found", + status=err.http_status if err is not None else 404, + ) return web.json_response( { + "ok": True, + "idempotent": not changed, "naga_id": naga_id, - "bound_qq": binding.qq_id, - "groups": available_groups, + "bind_uuid": bind_uuid, } ) diff --git a/src/Undefined/api/naga_store.py b/src/Undefined/api/naga_store.py index b07e7ea1..90b66429 100644 --- a/src/Undefined/api/naga_store.py +++ b/src/Undefined/api/naga_store.py @@ -1,4 +1,4 @@ -"""Naga 绑定存储 — scoped token 管理""" +"""Naga 绑定存储。""" from __future__ import annotations @@ -7,30 +7,36 @@ import os import secrets import time -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Literal, cast +from uuid import uuid4 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 +_STORE_VERSION = 3 _DATA_FILE = Path("data/naga_bindings.json") +_PENDING_TTL_SECONDS = 24 * 60 * 60 +_TERMINAL_RECORD_RETENTION_SECONDS = 30 * 24 * 60 * 60 + +CompletedRequestStatus = Literal["rejected", "cancelled", "expired"] +NagaStoreErrorCode = Literal["forbidden", "not_found", "conflict"] @dataclass class NagaBinding: - """已通过的 Naga 绑定""" + """当前代的 Naga 绑定。""" naga_id: str - token: str + bind_uuid: str + delivery_signature: str qq_id: int group_id: int created_at: float revoked: bool = False + revoked_at: float | None = None description: str = "" last_used_at: float | None = None use_count: int = 0 @@ -38,85 +44,266 @@ class NagaBinding: @dataclass class PendingBinding: - """待审核的绑定申请""" + """待远端确认的绑定申请。""" naga_id: str + bind_uuid: str qq_id: int group_id: int requested_at: float + request_context: dict[str, Any] = field(default_factory=dict) + submit_attempts: int = 0 + last_submit_attempt_at: float | None = None + + +@dataclass +class HistoricalBinding: + """按 bind_uuid 持久化的绑定历史,用于幂等和审计。""" + + naga_id: str + bind_uuid: str + delivery_signature: str + qq_id: int + group_id: int + created_at: float + revoked: bool = False + revoked_at: float | None = None + last_used_at: float | None = None + use_count: int = 0 + + +@dataclass +class CompletedBindRequest: + """终态的绑定请求记录。""" + + naga_id: str + bind_uuid: str + qq_id: int + group_id: int + status: CompletedRequestStatus + resolved_at: float + reason: str = "" + + +@dataclass(frozen=True) +class NagaStoreError: + """结构化错误,避免调用方靠字符串猜状态码。""" + + code: NagaStoreErrorCode + message: str + @property + def http_status(self) -> int: + return { + "forbidden": 403, + "not_found": 404, + "conflict": 409, + }[self.code] -def _generate_token() -> str: - return f"{_TOKEN_PREFIX}{secrets.token_hex(_TOKEN_HEX_BYTES)}" + +def generate_bind_uuid() -> str: + return uuid4().hex def mask_token(token: str) -> str: - """日志脱敏:只显示前 12 字符 + '...'""" + """日志脱敏:只显示前 12 字符 + '...'。""" if len(token) <= 12: return token return token[:12] + "..." -class NagaStore: - """Naga 绑定数据管理 +def _clone_binding(binding: NagaBinding) -> NagaBinding: + return NagaBinding(**asdict(binding)) + + +def _clone_pending(pending: PendingBinding) -> PendingBinding: + data = asdict(pending) + data["request_context"] = dict(pending.request_context) + return PendingBinding(**data) + + +def _clone_history(history: HistoricalBinding) -> HistoricalBinding: + return HistoricalBinding(**asdict(history)) - 内存缓存 + JSON 文件持久化,所有读操作 O(1)。 - """ + +def _binding_from_history(history: HistoricalBinding) -> NagaBinding: + return NagaBinding( + naga_id=history.naga_id, + bind_uuid=history.bind_uuid, + delivery_signature=history.delivery_signature, + qq_id=history.qq_id, + group_id=history.group_id, + created_at=history.created_at, + revoked=history.revoked, + revoked_at=history.revoked_at, + last_used_at=history.last_used_at, + use_count=history.use_count, + ) + + +class NagaStore: + """Naga 绑定数据管理。""" 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._history: dict[str, HistoricalBinding] = {} + self._completed_requests: dict[str, CompletedBindRequest] = {} + self._active_deliveries: dict[str, int] = {} self._lock = asyncio.Lock() + self._delivery_condition = asyncio.Condition(self._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 + self._bindings.clear() + self._pending.clear() + self._history.clear() + self._completed_requests.clear() + 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)), - ) + if not isinstance(data, dict): + continue + delivery_signature = str( + data.get("delivery_signature", data.get("token", "")) + ) + self._bindings[naga_id] = NagaBinding( + naga_id=str(data.get("naga_id", naga_id)), + bind_uuid=str(data.get("bind_uuid", "")), + delivery_signature=delivery_signature, + 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)), + revoked_at=( + float(data["revoked_at"]) + if data.get("revoked_at") is not None + else None + ), + description=str(data.get("description", "")), + last_used_at=( + float(data["last_used_at"]) + if data.get("last_used_at") is not None + else None + ), + 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)), - ) + if not isinstance(data, dict): + continue + request_context = data.get("request_context", {}) + if not isinstance(request_context, dict): + request_context = {} + self._pending[naga_id] = PendingBinding( + naga_id=str(data.get("naga_id", naga_id)), + bind_uuid=str(data.get("bind_uuid", "")), + qq_id=int(data.get("qq_id", 0)), + group_id=int(data.get("group_id", 0)), + requested_at=float(data.get("requested_at", 0)), + request_context=request_context, + submit_attempts=int(data.get("submit_attempts", 0)), + last_submit_attempt_at=( + float(data["last_submit_attempt_at"]) + if data.get("last_submit_attempt_at") is not None + else None + ), + ) + + history_raw = raw.get("history", {}) + if isinstance(history_raw, dict): + for bind_uuid, data in history_raw.items(): + if not isinstance(data, dict): + continue + self._history[bind_uuid] = HistoricalBinding( + naga_id=str(data.get("naga_id", "")), + bind_uuid=str(data.get("bind_uuid", bind_uuid)), + delivery_signature=str(data.get("delivery_signature", "")), + 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)), + revoked_at=( + float(data["revoked_at"]) + if data.get("revoked_at") is not None + else None + ), + last_used_at=( + float(data["last_used_at"]) + if data.get("last_used_at") is not None + else None + ), + use_count=int(data.get("use_count", 0)), + ) + + completed_raw = raw.get("completed_requests", {}) + if isinstance(completed_raw, dict): + for bind_uuid, data in completed_raw.items(): + if not isinstance(data, dict): + continue + status = str(data.get("status", "") or "").strip().lower() + if status not in {"rejected", "cancelled", "expired"}: + continue + status_literal = cast(CompletedRequestStatus, status) + self._completed_requests[bind_uuid] = CompletedBindRequest( + naga_id=str(data.get("naga_id", "")), + bind_uuid=str(data.get("bind_uuid", bind_uuid)), + qq_id=int(data.get("qq_id", 0)), + group_id=int(data.get("group_id", 0)), + status=status_literal, + resolved_at=float(data.get("resolved_at", 0)), + reason=str(data.get("reason", "")), + ) + + needs_save = False + for binding in self._bindings.values(): + if not binding.bind_uuid: + continue + if binding.bind_uuid in self._history: + continue + self._history[binding.bind_uuid] = HistoricalBinding( + naga_id=binding.naga_id, + bind_uuid=binding.bind_uuid, + delivery_signature=binding.delivery_signature, + qq_id=binding.qq_id, + group_id=binding.group_id, + created_at=binding.created_at, + revoked=binding.revoked, + revoked_at=binding.revoked_at, + last_used_at=binding.last_used_at, + use_count=binding.use_count, + ) + needs_save = True + + now = time.time() + if self._expire_pending_locked(now): + needs_save = True + if self._prune_terminal_records_locked(now): + needs_save = True logger.info( - "[NagaStore] 加载完成: bindings=%d pending=%d", + "[NagaStore] 加载完成: bindings=%d pending=%d history=%d completed=%d", len(self._bindings), len(self._pending), + len(self._history), + len(self._completed_requests), ) - + if needs_save: + await self.save() await asyncio.to_thread(self._restrict_permissions) def _restrict_permissions(self) -> None: - """限制数据文件权限(仅 Unix 生效)""" if os.name != "posix": return try: @@ -125,121 +312,518 @@ def _restrict_permissions(self) -> None: except OSError as exc: logger.debug("[NagaStore] chmod 600 失败: %s", exc) + def _expire_pending_locked(self, now: float) -> bool: + dirty = False + for naga_id, pending in list(self._pending.items()): + if now - pending.requested_at < _PENDING_TTL_SECONDS: + continue + removed = self._pending.pop(naga_id) + self._completed_requests[removed.bind_uuid] = CompletedBindRequest( + naga_id=removed.naga_id, + bind_uuid=removed.bind_uuid, + qq_id=removed.qq_id, + group_id=removed.group_id, + status="expired", + resolved_at=now, + reason="pending bind expired", + ) + logger.info( + "[NagaStore] 待绑定过期: naga_id=%s qq=%d group=%d bind_uuid=%s", + removed.naga_id, + removed.qq_id, + removed.group_id, + removed.bind_uuid, + ) + dirty = True + return dirty + + def _prune_terminal_records_locked(self, now: float) -> bool: + dirty = False + for bind_uuid, completed in list(self._completed_requests.items()): + if now - completed.resolved_at < _TERMINAL_RECORD_RETENTION_SECONDS: + continue + del self._completed_requests[bind_uuid] + dirty = True + + current_bind_uuids = {binding.bind_uuid for binding in self._bindings.values()} + for bind_uuid, history in list(self._history.items()): + if bind_uuid in current_bind_uuids: + continue + if not history.revoked or history.revoked_at is None: + continue + if now - history.revoked_at < _TERMINAL_RECORD_RETENTION_SECONDS: + continue + del self._history[bind_uuid] + dirty = True + return dirty + 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()}, + "history": {k: asdict(v) for k, v in self._history.items()}, + "completed_requests": { + k: asdict(v) for k, v in self._completed_requests.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) - """ + self, + naga_id: str, + qq_id: int, + group_id: int, + *, + bind_uuid: str | None = None, + request_context: dict[str, Any] | None = None, + ) -> tuple[bool, str, PendingBinding | None]: 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( + now = time.time() + dirty = self._expire_pending_locked(now) + dirty = self._prune_terminal_records_locked(now) or dirty + + active = self._bindings.get(naga_id) + if active is not None and not active.revoked: + if dirty: + await self.save() + return False, f"naga_id '{naga_id}' 已绑定", None + + pending = self._pending.get(naga_id) + if pending is not None: + snapshot = _clone_pending(pending) + if pending.qq_id == qq_id and pending.group_id == group_id: + if dirty: + await self.save() + return True, "申请已存在,等待 Naga 端确认", snapshot + if dirty: + await self.save() + return False, f"naga_id '{naga_id}' 已在处理中", None + + pending = PendingBinding( naga_id=naga_id, + bind_uuid=str(bind_uuid or generate_bind_uuid()), qq_id=qq_id, group_id=group_id, - requested_at=time.time(), + requested_at=now, + request_context=dict(request_context or {}), ) + self._pending[naga_id] = pending await self.save() - return True, "申请已提交,等待超管审核" - - async def approve(self, naga_id: str) -> NagaBinding | None: - """审批通过:生成 token,移入 bindings""" + return True, "申请已提交,等待 Naga 端确认", _clone_pending(pending) + + async def begin_remote_submit( + self, + naga_id: str, + *, + bind_uuid: str, + cooldown_seconds: float = 3.0, + ) -> tuple[PendingBinding | None, bool]: + async with self._lock: + now = time.time() + pending = self._pending.get(naga_id) + if pending is None or pending.bind_uuid != bind_uuid: + return None, False + if ( + pending.last_submit_attempt_at is not None + and now - pending.last_submit_attempt_at < cooldown_seconds + ): + return _clone_pending(pending), False + pending.last_submit_attempt_at = now + pending.submit_attempts += 1 + await self.save() + return _clone_pending(pending), True + + async def activate_binding( + self, + *, + bind_uuid: str, + naga_id: str, + delivery_signature: str, + ) -> tuple[NagaBinding | None, bool, NagaStoreError | None]: async with self._lock: - pending = self._pending.pop(naga_id, None) + now = time.time() + dirty = self._expire_pending_locked(now) + dirty = self._prune_terminal_records_locked(now) or dirty + + binding = self._bindings.get(naga_id) + if binding is not None and binding.bind_uuid == bind_uuid: + if not secrets.compare_digest( + binding.delivery_signature, delivery_signature + ): + if dirty: + await self.save() + return ( + None, + False, + NagaStoreError("forbidden", "delivery_signature 不匹配"), + ) + if dirty: + await self.save() + return _clone_binding(binding), False, None + if binding is not None and not binding.revoked: + if dirty: + await self.save() + return ( + None, + False, + NagaStoreError("conflict", f"naga_id '{naga_id}' 已绑定"), + ) + + historical = self._history.get(bind_uuid) + if historical is not None and historical.naga_id == naga_id: + if not secrets.compare_digest( + historical.delivery_signature, delivery_signature + ): + if dirty: + await self.save() + return ( + None, + False, + NagaStoreError("forbidden", "delivery_signature 不匹配"), + ) + if dirty: + await self.save() + return _binding_from_history(historical), False, None + + completed = self._completed_requests.get(bind_uuid) + if completed is not None and completed.naga_id == naga_id: + if dirty: + await self.save() + return ( + None, + False, + NagaStoreError( + "conflict", f"bind request already {completed.status}" + ), + ) + + pending = self._pending.get(naga_id) if pending is None: - return None + if dirty: + await self.save() + return ( + None, + False, + NagaStoreError( + "not_found", f"naga_id '{naga_id}' 未处于待绑定状态" + ), + ) + if pending.bind_uuid != bind_uuid: + if dirty: + await self.save() + return None, False, NagaStoreError("forbidden", "bind_uuid 不匹配") - token = _generate_token() binding = NagaBinding( naga_id=naga_id, - token=token, + bind_uuid=bind_uuid, + delivery_signature=delivery_signature, qq_id=pending.qq_id, group_id=pending.group_id, - created_at=time.time(), + created_at=now, ) self._bindings[naga_id] = binding + self._history[bind_uuid] = HistoricalBinding( + naga_id=naga_id, + bind_uuid=bind_uuid, + delivery_signature=delivery_signature, + qq_id=pending.qq_id, + group_id=pending.group_id, + created_at=now, + ) + self._pending.pop(naga_id, None) + self._completed_requests.pop(bind_uuid, None) await self.save() + logger.info( - "[NagaStore] 绑定审批通过: naga_id=%s qq=%d group=%d token=%s", + "[NagaStore] 绑定激活: naga_id=%s qq=%d group=%d signature=%s bind_uuid=%s", naga_id, binding.qq_id, binding.group_id, - mask_token(token), + mask_token(delivery_signature), + bind_uuid, ) - return binding + return _clone_binding(binding), True, None + + async def reject_binding( + self, + *, + bind_uuid: str, + naga_id: str, + reason: str = "", + ) -> tuple[PendingBinding | None, bool, NagaStoreError | None]: + async with self._lock: + now = time.time() + dirty = self._expire_pending_locked(now) + dirty = self._prune_terminal_records_locked(now) or dirty + + pending = self._pending.get(naga_id) + if pending is not None and pending.bind_uuid == bind_uuid: + removed = self._pending.pop(naga_id) + self._completed_requests[bind_uuid] = CompletedBindRequest( + naga_id=removed.naga_id, + bind_uuid=removed.bind_uuid, + qq_id=removed.qq_id, + group_id=removed.group_id, + status="rejected", + resolved_at=now, + reason=reason, + ) + await self.save() + logger.info( + "[NagaStore] 绑定被远端拒绝: naga_id=%s qq=%d group=%d bind_uuid=%s", + naga_id, + removed.qq_id, + removed.group_id, + bind_uuid, + ) + return _clone_pending(removed), True, None + + binding = self._bindings.get(naga_id) + if binding is not None and binding.bind_uuid == bind_uuid: + if dirty: + await self.save() + return ( + None, + False, + NagaStoreError("conflict", "bind request already approved"), + ) + + completed = self._completed_requests.get(bind_uuid) + if completed is not None and completed.naga_id == naga_id: + if dirty: + await self.save() + if completed.status == "rejected": + return None, False, None + return ( + None, + False, + NagaStoreError( + "conflict", f"bind request already {completed.status}" + ), + ) + + if dirty: + await self.save() + return ( + None, + False, + NagaStoreError("not_found", f"naga_id '{naga_id}' 未处于待绑定状态"), + ) - async def reject(self, naga_id: str) -> bool: - """拒绝绑定申请""" + async def cancel_pending( + self, + naga_id: str, + *, + bind_uuid: str | None = None, + reason: str = "cancelled", + ) -> PendingBinding | None: async with self._lock: - if naga_id not in self._pending: - return False - del self._pending[naga_id] + pending = self._pending.get(naga_id) + if pending is None: + return None + if bind_uuid is not None and pending.bind_uuid != bind_uuid: + return None + removed = self._pending.pop(naga_id) + self._completed_requests[removed.bind_uuid] = CompletedBindRequest( + naga_id=removed.naga_id, + bind_uuid=removed.bind_uuid, + qq_id=removed.qq_id, + group_id=removed.group_id, + status="cancelled", + resolved_at=time.time(), + reason=reason, + ) await self.save() - logger.info("[NagaStore] 绑定申请已拒绝: naga_id=%s", naga_id) - return True + return _clone_pending(removed) + + async def revoke_binding( + self, + naga_id: str, + *, + expected_bind_uuid: str | None = None, + delivery_signature: str | None = None, + wait_for_delivery: bool = True, + ) -> tuple[NagaBinding | None, bool, NagaStoreError | None]: + async with self._delivery_condition: + now = time.time() + dirty = self._expire_pending_locked(now) + dirty = self._prune_terminal_records_locked(now) or dirty + + def _historical_match() -> NagaBinding | None: + if not expected_bind_uuid: + return None + historical = self._history.get(expected_bind_uuid) + if historical is None or historical.naga_id != naga_id: + return None + if delivery_signature is not None and not secrets.compare_digest( + historical.delivery_signature, delivery_signature + ): + return None + return _binding_from_history(historical) + + current = self._bindings.get(naga_id) + if current is None: + historical_binding = _historical_match() + if dirty: + await self.save() + if historical_binding is not None and historical_binding.revoked: + return historical_binding, False, None + return None, False, NagaStoreError("not_found", "binding not found") + + if ( + expected_bind_uuid is not None + and current.bind_uuid != expected_bind_uuid + ): + historical_binding = _historical_match() + if dirty: + await self.save() + if historical_binding is not None and historical_binding.revoked: + return historical_binding, False, None + if historical_binding is not None: + return ( + None, + False, + NagaStoreError("conflict", "binding generation is not current"), + ) + return None, False, NagaStoreError("conflict", "bind_uuid 不匹配") + + if delivery_signature is not None and not secrets.compare_digest( + current.delivery_signature, delivery_signature + ): + if dirty: + await self.save() + return ( + None, + False, + NagaStoreError("forbidden", "delivery_signature 不匹配"), + ) + + changed = False + if not current.revoked: + current.revoked = True + current.revoked_at = now + historical = self._history.get(current.bind_uuid) + if historical is not None: + historical.revoked = True + historical.revoked_at = now + changed = True + await self.save() + elif dirty: + await self.save() + + if wait_for_delivery: + while self._active_deliveries.get(current.bind_uuid, 0) > 0: + await self._delivery_condition.wait() + + logger.info( + "[NagaStore] 绑定已吊销: naga_id=%s bind_uuid=%s changed=%s", + naga_id, + current.bind_uuid, + changed, + ) + return _clone_binding(current), changed, None async def revoke(self, naga_id: str) -> bool: - """吊销已有绑定""" + binding, changed, _ = await self.revoke_binding(naga_id) + return binding is not None and changed + + async def acquire_delivery( + self, *, naga_id: str, bind_uuid: str, delivery_signature: str + ) -> tuple[NagaBinding | None, NagaStoreError | None]: 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 + if binding is None: + return None, NagaStoreError("forbidden", f"naga_id '{naga_id}' 未绑定") + if binding.revoked: + return None, NagaStoreError( + "forbidden", f"naga_id '{naga_id}' 绑定已吊销" + ) + if binding.bind_uuid != bind_uuid: + return None, NagaStoreError("forbidden", "bind_uuid 不匹配") + if not secrets.compare_digest( + binding.delivery_signature, delivery_signature + ): + return None, NagaStoreError("forbidden", "delivery_signature 不匹配") + self._active_deliveries[bind_uuid] = ( + self._active_deliveries.get(bind_uuid, 0) + 1 + ) + return _clone_binding(binding), None + + async def ensure_delivery_active( + self, *, naga_id: str, bind_uuid: str + ) -> tuple[NagaBinding | None, NagaStoreError | None]: + async with self._lock: + binding = self._bindings.get(naga_id) + if binding is None: + return None, NagaStoreError("conflict", f"naga_id '{naga_id}' 未绑定") + if binding.bind_uuid != bind_uuid: + return None, NagaStoreError("conflict", "binding generation changed") + if binding.revoked: + return None, NagaStoreError( + "conflict", f"naga_id '{naga_id}' 绑定已吊销" + ) + return _clone_binding(binding), None + + async def release_delivery(self, *, bind_uuid: str) -> None: + async with self._delivery_condition: + count = self._active_deliveries.get(bind_uuid, 0) + if count <= 1: + self._active_deliveries.pop(bind_uuid, None) + self._delivery_condition.notify_all() + return + self._active_deliveries[bind_uuid] = count - 1 + self._delivery_condition.notify_all() def list_bindings(self) -> list[NagaBinding]: - """列出所有活跃绑定""" - return [b for b in self._bindings.values() if not b.revoked] + return [_clone_binding(b) for b in self._bindings.values() if not b.revoked] def list_pending(self) -> list[PendingBinding]: - """列出所有待审核申请""" - return list(self._pending.values()) + return [_clone_pending(p) for p in self._pending.values()] def get_binding(self, naga_id: str) -> NagaBinding | None: - """按 naga_id 查询绑定""" - return self._bindings.get(naga_id) + binding = self._bindings.get(naga_id) + return _clone_binding(binding) if binding is not None else None + + def get_pending(self, naga_id: str) -> PendingBinding | None: + pending = self._pending.get(naga_id) + return _clone_pending(pending) if pending is not None else None - def verify(self, naga_id: str, token: str) -> tuple[bool, str]: - """校验 scoped token(纯内存操作) + def get_binding_history(self, bind_uuid: str) -> HistoricalBinding | None: + history = self._history.get(bind_uuid) + return _clone_history(history) if history is not None else None - Returns: - (valid, error_message) - """ + def verify_delivery( + self, *, naga_id: str, bind_uuid: str, delivery_signature: str + ) -> tuple[NagaBinding | None, str]: binding = self._bindings.get(naga_id) if binding is None: - return False, f"naga_id '{naga_id}' 未绑定" + return None, 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: - """更新使用记录""" + return None, f"naga_id '{naga_id}' 绑定已吊销" + if binding.bind_uuid != bind_uuid: + return None, "bind_uuid 不匹配" + if not secrets.compare_digest(binding.delivery_signature, delivery_signature): + return None, "delivery_signature 不匹配" + return _clone_binding(binding), "" + + async def record_usage(self, naga_id: str, *, bind_uuid: str) -> None: async with self._lock: + now = time.time() + dirty = False + + historical = self._history.get(bind_uuid) + if historical is not None and historical.naga_id == naga_id: + historical.last_used_at = now + historical.use_count += 1 + dirty = True + binding = self._bindings.get(naga_id) - if binding is None: - return - binding.last_used_at = time.time() - binding.use_count += 1 - await self.save() + if binding is not None and binding.bind_uuid == bind_uuid: + binding.last_used_at = now + binding.use_count += 1 + dirty = True + + if dirty: + await self.save() diff --git a/src/Undefined/cognitive/historian.py b/src/Undefined/cognitive/historian.py index eb32ca77..4563f0b8 100644 --- a/src/Undefined/cognitive/historian.py +++ b/src/Undefined/cognitive/historian.py @@ -9,6 +9,8 @@ from datetime import datetime, timezone from typing import Any, Callable +from Undefined.utils.tool_calls import extract_required_tool_call_arguments + logger = logging.getLogger(__name__) _MAX_LOG_PREVIEW_LEN = 200 @@ -367,60 +369,19 @@ def _extract_required_tool_args( suffix += f" attempt={attempt}" if target: suffix += f" target={target}" - - choices = response.get("choices") - if not isinstance(choices, list) or not choices: - logger.error("[史官] 任务 %s 响应缺少 choices:%s", job_id, suffix) - raise ValueError(f"{stage} 响应缺少 choices") - - message = choices[0].get("message") if isinstance(choices[0], dict) else None - if not isinstance(message, dict): - logger.error("[史官] 任务 %s 响应缺少 message:%s", job_id, suffix) - raise ValueError(f"{stage} 响应缺少 message") - - tool_calls = message.get("tool_calls") - if not isinstance(tool_calls, list) or not tool_calls: - logger.error( - "[史官] 任务 %s 响应缺少 tool_calls:%s content_preview=%s", - job_id, - suffix, - _preview_text(str(message.get("content", ""))), - ) - raise ValueError(f"{stage} 响应缺少 tool_calls") - - tool_call = tool_calls[0] - function = tool_call.get("function", {}) if isinstance(tool_call, dict) else {} - tool_name = str(function.get("name", "")).strip() - if tool_name != expected_tool_name: - logger.error( - "[史官] 任务 %s 工具名不匹配:%s actual_tool=%s", - job_id, - suffix, - tool_name, - ) - raise ValueError(f"{stage} 工具名不匹配: {tool_name}") - - raw_args = str(function.get("arguments", "{}")) try: - args = json.loads(raw_args) - except json.JSONDecodeError as exc: - logger.error( - "[史官] 任务 %s 工具参数 JSON 解析失败:%s err=%s raw_preview=%s", - job_id, - suffix, - exc, - _preview_text(raw_args), + return extract_required_tool_call_arguments( + response, + expected_tool_name=expected_tool_name, + stage=stage, + logger=logger, + error_context=f"job_id={job_id}{suffix}", ) - raise - if not isinstance(args, dict): + except Exception as exc: logger.error( - "[史官] 任务 %s 工具参数类型非法:%s type=%s", - job_id, - suffix, - type(args).__name__, + "[史官] 任务 %s 提取工具参数失败:%s err=%s", job_id, suffix, exc ) - raise ValueError(f"{stage} 工具参数类型非法") - return args + raise async def _rewrite( self, diff --git a/src/Undefined/config/hot_reload.py b/src/Undefined/config/hot_reload.py index 09c6565e..0dcfbc46 100644 --- a/src/Undefined/config/hot_reload.py +++ b/src/Undefined/config/hot_reload.py @@ -8,6 +8,7 @@ from Undefined.ai import AIClient from Undefined.config import Config from Undefined.config.manager import ConfigManager +from Undefined.services.security import SecurityService from Undefined.services.queue_manager import QueueManager from Undefined.skills.agents.intro_generator import AgentIntroGenConfig from Undefined.utils.queue_intervals import build_model_queue_intervals @@ -40,6 +41,7 @@ "chat_model.queue_interval_seconds", "vision_model.queue_interval_seconds", "security_model.queue_interval_seconds", + "naga_model.queue_interval_seconds", "agent_model.queue_interval_seconds", "chat_model.pool", "agent_model.pool", @@ -49,6 +51,7 @@ "chat_model.model_name", "vision_model.model_name", "security_model.model_name", + "naga_model.model_name", "agent_model.model_name", } @@ -78,6 +81,7 @@ class HotReloadContext: ai_client: AIClient queue_manager: QueueManager config_manager: ConfigManager + security_service: SecurityService def apply_config_updates( @@ -91,6 +95,7 @@ def apply_config_updates( changed_keys = set(changes.keys()) logger.debug("[配置] 热更新变更项: %s", ", ".join(sorted(changed_keys))) _log_restart_required(changed_keys) + context.security_service.apply_config(updated) if _needs_queue_interval_update(changed_keys): context.queue_manager.update_model_intervals( diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 7ff8f5f2..66509328 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -464,6 +464,7 @@ class Config: vision_model: VisionModelConfig security_model_enabled: bool security_model: SecurityModelConfig + naga_model: SecurityModelConfig agent_model: AgentModelConfig historian_model: AgentModelConfig model_pool_enabled: bool @@ -786,6 +787,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi True, ) security_model = cls._parse_security_model_config(data, chat_model) + naga_model = cls._parse_naga_model_config(data, security_model) agent_model = cls._parse_agent_model_config(data) historian_model = cls._parse_historian_model_config(data, agent_model) @@ -1258,6 +1260,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi chat_model, vision_model, security_model, + naga_model, agent_model, ) @@ -1286,6 +1289,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi vision_model=vision_model, security_model_enabled=security_model_enabled, security_model=security_model, + naga_model=naga_model, agent_model=agent_model, historian_model=historian_model, model_pool_enabled=model_pool_enabled, @@ -2040,6 +2044,135 @@ def _parse_security_model_config( request_params=merge_request_params(chat_model.request_params), ) + @staticmethod + def _parse_naga_model_config( + data: dict[str, Any], security_model: SecurityModelConfig + ) -> SecurityModelConfig: + api_url = _coerce_str( + _get_value(data, ("models", "naga", "api_url"), "NAGA_MODEL_API_URL"), + "", + ) + api_key = _coerce_str( + _get_value(data, ("models", "naga", "api_key"), "NAGA_MODEL_API_KEY"), + "", + ) + model_name = _coerce_str( + _get_value(data, ("models", "naga", "model_name"), "NAGA_MODEL_NAME"), + "", + ) + queue_interval_seconds = _coerce_float( + _get_value( + data, + ("models", "naga", "queue_interval_seconds"), + "NAGA_MODEL_QUEUE_INTERVAL", + ), + security_model.queue_interval_seconds, + ) + if queue_interval_seconds <= 0: + queue_interval_seconds = 1.0 + + thinking_include_budget, thinking_tool_call_compat = ( + _resolve_thinking_compat_flags( + data=data, + model_name="naga", + include_budget_env_key="NAGA_MODEL_THINKING_INCLUDE_BUDGET", + tool_call_compat_env_key="NAGA_MODEL_THINKING_TOOL_CALL_COMPAT", + legacy_env_key="NAGA_MODEL_DEEPSEEK_NEW_COT_SUPPORT", + ) + ) + api_mode = _resolve_api_mode(data, "naga", "NAGA_MODEL_API_MODE") + responses_tool_choice_compat = _resolve_responses_tool_choice_compat( + data, "naga", "NAGA_MODEL_RESPONSES_TOOL_CHOICE_COMPAT" + ) + responses_force_stateless_replay = _resolve_responses_force_stateless_replay( + data, "naga", "NAGA_MODEL_RESPONSES_FORCE_STATELESS_REPLAY" + ) + reasoning_enabled = _coerce_bool( + _get_value( + data, + ("models", "naga", "reasoning_enabled"), + "NAGA_MODEL_REASONING_ENABLED", + ), + getattr(security_model, "reasoning_enabled", False), + ) + reasoning_effort = _resolve_reasoning_effort( + _get_value( + data, + ("models", "naga", "reasoning_effort"), + "NAGA_MODEL_REASONING_EFFORT", + ), + getattr(security_model, "reasoning_effort", "medium"), + ) + + if api_url and api_key and model_name: + return SecurityModelConfig( + api_url=api_url, + api_key=api_key, + model_name=model_name, + max_tokens=_coerce_int( + _get_value( + data, + ("models", "naga", "max_tokens"), + "NAGA_MODEL_MAX_TOKENS", + ), + 160, + ), + queue_interval_seconds=queue_interval_seconds, + api_mode=api_mode, + thinking_enabled=_coerce_bool( + _get_value( + data, + ("models", "naga", "thinking_enabled"), + "NAGA_MODEL_THINKING_ENABLED", + ), + False, + ), + thinking_budget_tokens=_coerce_int( + _get_value( + data, + ("models", "naga", "thinking_budget_tokens"), + "NAGA_MODEL_THINKING_BUDGET_TOKENS", + ), + 0, + ), + thinking_include_budget=thinking_include_budget, + reasoning_effort_style=_resolve_reasoning_effort_style( + _get_value( + data, + ("models", "naga", "reasoning_effort_style"), + "NAGA_MODEL_REASONING_EFFORT_STYLE", + ), + ), + thinking_tool_call_compat=thinking_tool_call_compat, + responses_tool_choice_compat=responses_tool_choice_compat, + responses_force_stateless_replay=responses_force_stateless_replay, + reasoning_enabled=reasoning_enabled, + reasoning_effort=reasoning_effort, + request_params=_get_model_request_params(data, "naga"), + ) + + logger.info( + "未配置 Naga 审核模型,将使用已解析的安全模型配置作为后备(安全模型本身可能已回退)" + ) + return SecurityModelConfig( + api_url=security_model.api_url, + api_key=security_model.api_key, + model_name=security_model.model_name, + max_tokens=security_model.max_tokens, + queue_interval_seconds=security_model.queue_interval_seconds, + api_mode=security_model.api_mode, + thinking_enabled=security_model.thinking_enabled, + thinking_budget_tokens=security_model.thinking_budget_tokens, + thinking_include_budget=security_model.thinking_include_budget, + reasoning_effort_style=security_model.reasoning_effort_style, + thinking_tool_call_compat=security_model.thinking_tool_call_compat, + responses_tool_choice_compat=security_model.responses_tool_choice_compat, + responses_force_stateless_replay=security_model.responses_force_stateless_replay, + reasoning_enabled=security_model.reasoning_enabled, + reasoning_effort=security_model.reasoning_effort, + request_params=merge_request_params(security_model.request_params), + ) + @staticmethod def _parse_agent_model_config(data: dict[str, Any]) -> AgentModelConfig: queue_interval_seconds = _coerce_float( @@ -2198,6 +2331,7 @@ def _log_debug_info( chat_model: ChatModelConfig, vision_model: VisionModelConfig, security_model: SecurityModelConfig, + naga_model: SecurityModelConfig, agent_model: AgentModelConfig, ) -> None: configs: list[ @@ -2212,6 +2346,7 @@ def _log_debug_info( ("chat", chat_model), ("vision", vision_model), ("security", security_model), + ("naga", naga_model), ("agent", agent_model), ] for name, cfg in configs: diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index 3b073d11..27419c1a 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -195,7 +195,7 @@ class NagaConfig: 开关分层: - ``features.nagaagent_mode_enabled`` — 控制 AI 侧行为(提示词切换、工具暴露) - - ``naga.enabled`` — 控制外部网关集成(回调 API、/naga 命令、绑定管理) + - ``naga.enabled`` — 控制外部网关集成(/naga、绑定回调、消息发送、解绑) 两者均默认 False。可单独开启 ``nagaagent_mode_enabled`` 获得 NagaAgent 解答能力, 无需启用外部回调联动。 diff --git a/src/Undefined/main.py b/src/Undefined/main.py index 14d1d5e8..ce21cd94 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -378,6 +378,7 @@ async def main() -> None: ai_client=ai, queue_manager=handler.queue_manager, config_manager=config_manager, + security_service=handler.security, ) def _apply_config_updates( @@ -436,6 +437,11 @@ def _apply_config_updates( logger.exception("[RuntimeAPI] 启动失败,已跳过: %s", exc) else: logger.info("[RuntimeAPI] 已禁用(api.enabled=false)") + if config.nagaagent_mode_enabled and config.naga.enabled: + logger.warning( + "[Naga] 已启用 nagaagent_mode_enabled 与 naga.enabled,但 Runtime API 已关闭;" + "/naga 命令和 /api/v1/naga/* 端点都不会可用" + ) try: await onebot.run_with_reconnect() diff --git a/src/Undefined/services/commands/registry.py b/src/Undefined/services/commands/registry.py index 86d5d579..9fe3df2d 100644 --- a/src/Undefined/services/commands/registry.py +++ b/src/Undefined/services/commands/registry.py @@ -16,11 +16,13 @@ logger = logging.getLogger(__name__) CommandHandler = Callable[[list[str], CommandContext], Awaitable[None]] -CommandSnapshot = tuple[int | None, int | None, int | None] +CommandVisibilityChecker = Callable[[CommandContext], bool] +CommandSnapshot = tuple[int | None, int | None, int | None, int | None] _COMMAND_CONFIG_FILENAME = "config.json" _COMMAND_HANDLER_FILENAME = "handler.py" _COMMAND_DOC_FILENAME = "README.md" +_COMMAND_POLICY_FILENAME = "policy.py" _RELOAD_SCAN_INTERVAL_SECONDS = 0.2 @@ -51,7 +53,10 @@ class CommandMeta: handler_path: Path doc_path: Path | None module_name: str + visibility_path: Path | None + visibility_module_name: str | None handler: CommandHandler | None = None + visibility_checker: CommandVisibilityChecker | None = None class CommandRegistry: @@ -165,6 +170,20 @@ def _load_command_dir( if (command_dir / _COMMAND_DOC_FILENAME).exists() else None, module_name=module_name, + visibility_path=(command_dir / _COMMAND_POLICY_FILENAME) + if (command_dir / _COMMAND_POLICY_FILENAME).exists() + else None, + visibility_module_name=".".join( + [ + "Undefined", + "skills", + "commands", + command_dir.name, + "policy", + ] + ) + if (command_dir / _COMMAND_POLICY_FILENAME).exists() + else None, ) if name in commands: logger.warning( @@ -260,6 +279,7 @@ def _build_snapshot(self) -> dict[str, CommandSnapshot]: self._read_mtime_ns(command_dir / _COMMAND_CONFIG_FILENAME), self._read_mtime_ns(command_dir / _COMMAND_HANDLER_FILENAME), self._read_mtime_ns(command_dir / _COMMAND_DOC_FILENAME), + self._read_mtime_ns(command_dir / _COMMAND_POLICY_FILENAME), ) return snapshot @@ -291,6 +311,28 @@ def list_commands(self, *, include_hidden: bool = False) -> list[CommandMeta]: items = [item for item in items if item.show_in_help] return sorted(items, key=lambda item: (item.order, item.name)) + def is_visible(self, command: CommandMeta, context: CommandContext) -> bool: + self.maybe_reload() + with self._lock: + checker = command.visibility_checker + if ( + checker is None + and command.visibility_path is not None + and command.visibility_module_name is not None + ): + checker = self._load_visibility_checker(command) + command.visibility_checker = checker + if checker is None: + return True + try: + return bool(checker(context)) + except Exception: + logger.exception( + "[CommandRegistry] 命令可见性检查失败,已隐藏: /%s", + command.name, + ) + return False + async def execute( self, command: CommandMeta, @@ -330,18 +372,7 @@ async def execute( raise def _load_handler(self, command: CommandMeta) -> CommandHandler: - sys.modules.pop(command.module_name, None) - module = types.ModuleType(command.module_name) - module.__file__ = str(command.handler_path) - module.__package__ = command.module_name.rpartition(".")[0] - source = command.handler_path.read_text(encoding="utf-8") - code = compile(source, str(command.handler_path), "exec") - sys.modules[command.module_name] = module - try: - exec(code, module.__dict__) - except Exception: - sys.modules.pop(command.module_name, None) - raise + module = self._load_module(command.module_name, command.handler_path) execute = getattr(module, "execute", None) if execute is None: raise RuntimeError(f"命令处理器缺少 execute: {command.handler_path}") @@ -357,3 +388,44 @@ def _load_handler(self, command: CommandMeta) -> CommandHandler: command.module_name, ) return cast(CommandHandler, execute) + + def _load_visibility_checker( + self, command: CommandMeta + ) -> CommandVisibilityChecker | None: + assert command.visibility_path is not None + assert command.visibility_module_name is not None + module = self._load_module( + command.visibility_module_name, command.visibility_path + ) + checker = getattr(module, "is_command_visible", None) + if checker is None: + logger.debug( + "[CommandRegistry] 可见性模块未声明 is_command_visible: /%s", + command.name, + ) + return None + if not callable(checker): + raise RuntimeError(f"命令可见性函数不可调用: {command.visibility_path}") + if asyncio.iscoroutinefunction(checker): + raise RuntimeError(f"命令可见性函数不能是 async: {command.visibility_path}") + logger.info( + "[CommandRegistry] 命令可见性策略已加载: /%s module=%s", + command.name, + command.visibility_module_name, + ) + return cast(CommandVisibilityChecker, checker) + + def _load_module(self, module_name: str, path: Path) -> types.ModuleType: + sys.modules.pop(module_name, None) + module = types.ModuleType(module_name) + module.__file__ = str(path) + module.__package__ = module_name.rpartition(".")[0] + source = path.read_text(encoding="utf-8") + code = compile(source, str(path), "exec") + sys.modules[module_name] = module + try: + exec(code, module.__dict__) + except Exception: + sys.modules.pop(module_name, None) + raise + return module diff --git a/src/Undefined/services/security.py b/src/Undefined/services/security.py index 50857fd3..42450840 100644 --- a/src/Undefined/services/security.py +++ b/src/Undefined/services/security.py @@ -1,7 +1,11 @@ import logging +import re import time -from typing import Any, Optional +from dataclasses import dataclass +from html.parser import HTMLParser +from typing import Any, Optional, cast import httpx +import markdown from Undefined.config import Config from Undefined.rate_limit import RateLimiter @@ -11,11 +15,61 @@ from Undefined.ai.transports import API_MODE_CHAT_COMPLETIONS, get_api_mode from Undefined.ai.parsing import extract_choices_content from Undefined.utils.resources import read_text_resource +from Undefined.utils.tool_calls import extract_required_tool_call_arguments from Undefined.utils.xml import escape_xml_text, escape_xml_attr logger = logging.getLogger(__name__) _INJECTION_DETECTION_SYSTEM_PROMPT: str | None = None +_NAGA_MESSAGE_MODERATION_PROMPT: str | None = None +_ALLOWED_NAGA_BLOCK_CATEGORIES = { + "pornography", + "politics_illegal", + "personal_privacy", +} +_NAGA_MESSAGE_MODERATION_TOOL = { + "type": "function", + "function": { + "name": "submit_naga_moderation_result", + "description": "提交 Naga 外发消息审核结果", + "parameters": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "enum": ["allow", "block"], + "description": "审核决策", + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "pornography", + "politics_illegal", + "personal_privacy", + ], + }, + "description": "命中的风险类别;允许时为空数组", + }, + "reason": { + "type": "string", + "description": "简短中文原因", + }, + }, + "required": ["decision", "categories", "reason"], + }, + }, +} + + +@dataclass +class NagaModerationResult: + blocked: bool + status: str + categories: list[str] + message: str + model_name: str def _get_injection_detection_prompt() -> str: @@ -34,6 +88,64 @@ def _get_injection_detection_prompt() -> str: return _INJECTION_DETECTION_SYSTEM_PROMPT +def _get_naga_message_moderation_prompt() -> str: + global _NAGA_MESSAGE_MODERATION_PROMPT + if _NAGA_MESSAGE_MODERATION_PROMPT is not None: + return _NAGA_MESSAGE_MODERATION_PROMPT + try: + _NAGA_MESSAGE_MODERATION_PROMPT = read_text_resource( + "res/prompts/naga_message_moderation.txt" + ) + except Exception as exc: + logger.error("加载 Naga 审核提示词失败: %s", exc) + _NAGA_MESSAGE_MODERATION_PROMPT = ( + "你是内容安全审计助手。" + "必须调用 submit_naga_moderation_result 提交审核结果。" + ) + return _NAGA_MESSAGE_MODERATION_PROMPT + + +class _HTMLTextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._chunks: list[str] = [] + + def handle_data(self, data: str) -> None: + text = data.strip() + if text: + self._chunks.append(text) + + def text(self) -> str: + return " ".join(self._chunks).strip() + + +def _strip_html_text(raw_html: str) -> str: + parser = _HTMLTextExtractor() + try: + parser.feed(raw_html) + parser.close() + return parser.text() + except Exception: + return raw_html + + +def _collapse_text(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def _renderless_text(message_format: str, content: str) -> str: + fmt = str(message_format or "text").strip().lower() + if fmt == "html": + return _collapse_text(_strip_html_text(content)) + if fmt == "markdown": + try: + html_content = markdown.markdown(content) + return _collapse_text(_strip_html_text(html_content)) + except Exception: + return _collapse_text(content) + return _collapse_text(content) + + class SecurityService: """安全服务,负责注入检测、速率限制和注入响应""" @@ -47,6 +159,14 @@ def __init__(self, config: Config, http_client: httpx.AsyncClient) -> None: config.security_model, self._requester ) + def apply_config(self, config: Config) -> None: + """应用热更新后的配置到安全服务。""" + self.config = config + self.rate_limiter.config = config + self.injection_response_agent = InjectionResponseAgent( + config.security_model, self._requester + ) + async def detect_injection( self, text: str, message_content: Optional[list[dict[str, Any]]] = None ) -> bool: @@ -153,3 +273,94 @@ def record_rate_limit(self, user_id: int) -> None: async def generate_injection_response(self, original_message: str) -> str: """生成注入攻击响应""" return await self.injection_response_agent.generate_response(original_message) + + async def moderate_naga_message( + self, *, message_format: str, content: str + ) -> NagaModerationResult: + """审核 Naga 外发消息。""" + model_config = getattr(self.config, "naga_model", self.config.security_model) + renderless_text = _renderless_text(message_format, content) + start_time = time.perf_counter() + request_kwargs: dict[str, Any] = {} + try: + if ( + get_api_mode(model_config) == API_MODE_CHAT_COMPLETIONS + and not model_config.thinking_enabled + ): + request_kwargs["thinking"] = {"enabled": False, "budget_tokens": 0} + + prompt_input = ( + "\n" + f"{escape_xml_text(message_format)}\n" + f"{escape_xml_text(content)}\n" + f"{escape_xml_text(renderless_text)}\n" + "" + ) + + result = await self._requester.request( + model_config=model_config, + messages=[ + { + "role": "system", + "content": _get_naga_message_moderation_prompt(), + }, + {"role": "user", "content": prompt_input}, + ], + tools=[_NAGA_MESSAGE_MODERATION_TOOL], + tool_choice=cast( + Any, + { + "type": "function", + "function": {"name": "submit_naga_moderation_result"}, + }, + ), + max_tokens=160, + call_type="naga_message_moderation", + **request_kwargs, + ) + parsed = extract_required_tool_call_arguments( + result, + expected_tool_name="submit_naga_moderation_result", + stage="naga_message_moderation", + logger=logger, + ) + decision = str(parsed.get("decision", "") or "").strip().lower() + raw_categories = parsed.get("categories", []) + categories = ( + [ + str(item).strip().lower() + for item in raw_categories + if str(item).strip() + ] + if isinstance(raw_categories, list) + else [] + ) + reason = str(parsed.get("reason", "") or "").strip() + block_hit = decision == "block" and any( + item in _ALLOWED_NAGA_BLOCK_CATEGORIES for item in categories + ) + duration = time.perf_counter() - start_time + logger.info( + "[安全] Naga 审核完成: blocked=%s categories=%s duration=%.2fs model=%s", + block_hit, + ",".join(categories) or "-", + duration, + model_config.model_name, + ) + return NagaModerationResult( + blocked=block_hit, + status="blocked" if block_hit else "passed", + categories=categories, + message=reason, + model_name=model_config.model_name, + ) + except Exception as exc: + duration = time.perf_counter() - start_time + logger.exception("[安全] Naga 审核失败: %s duration=%.2fs", exc, duration) + return NagaModerationResult( + blocked=False, + status="error_allowed", + categories=[], + message=f"审核异常,已按允许发送处理: {exc}", + model_name=model_config.model_name, + ) diff --git a/src/Undefined/skills/commands/help/handler.py b/src/Undefined/skills/commands/help/handler.py index 32b87cb1..dc3dbc79 100644 --- a/src/Undefined/skills/commands/help/handler.py +++ b/src/Undefined/skills/commands/help/handler.py @@ -42,6 +42,7 @@ def _format_command_list(context: CommandContext) -> str: 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 context.registry.is_visible(item, context)] # 按权限过滤:非管理员看不到管理命令 commands = [ @@ -103,6 +104,8 @@ def _format_command_detail(command_name: str, context: CommandContext) -> str | meta = context.registry.resolve(command_name) if meta is None: return None + if not context.registry.is_visible(meta, context): + 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): diff --git a/src/Undefined/skills/commands/naga/README.md b/src/Undefined/skills/commands/naga/README.md index a7c285a5..17ed36fb 100644 --- a/src/Undefined/skills/commands/naga/README.md +++ b/src/Undefined/skills/commands/naga/README.md @@ -2,65 +2,62 @@ ## 这是什么? -NagaAgent 是一个可以接入 Undefined 的外部 AI 助手。 -通过 `/naga` 命令,你可以把自己的 NagaAgent 绑定到 QQ 群,绑定之后,NagaAgent 里的特定功能就可以向你发送消息。 +`/naga` 用于把 QQ 用户与 NagaAgent 的远端身份绑定起来,并在需要时解除绑定。 +当前只保留两个子命令: -## 普通用户 +- `/naga bind ` +- `/naga unbind ` -普通用户只需要用到一个子命令:`bind`(绑定)。 +## 可见性与作用域 -### 如何绑定? +- `/naga` 只会在 `naga.allowed_groups` 白名单群中出现和生效 +- 同时要求 `[api].enabled = true`,否则命令会整体隐藏 +- 在非白名单群中,`/naga` 对用户是静默不可见的 +- `/naga bind` 仅限白名单群聊 +- `/naga unbind` 仅限超级管理员,可在私聊或白名单群中使用 -1. 在**群聊**中发送:`/naga bind <你的naga_id>` -2. 系统会提示"申请已提交,等待超管审核" -3. 超级管理员审核通过后,你会收到私聊通知 -4. 绑定完成!你的 NagaAgent 即可开始使用 +## /naga bind -### 注意事项 +用户在白名单群中执行: -- `naga_id` 是你在 NagaAgent 中设置的标识,不是 QQ 号 -- 每个 `naga_id` 只能绑定一次,不能重复申请 -- 如果已在审核队列中,无需重复提交 +```text +/naga bind <你的_naga_id> +``` + +流程: -## 管理员命令(仅超级管理员) +1. Undefined 本地记录一个待确认绑定,并生成唯一 `bind_uuid` +2. 请求会被发送到 Naga 端进行远端验证 +3. 等待 Naga 端通过 Runtime API 回调确认 +4. 回调成功后,Undefined 才会真正激活绑定并保存 `delivery_signature` +5. 如果远端暂时不可达,本地 pending 会保留;再次执行同一个 `/naga bind` 会沿用原来的 `bind_uuid` 继续重试 -以下命令仅超级管理员可使用,用于管理所有绑定: +这意味着 `/naga bind` 的成功提示只代表“请求已提交到 Naga 端”,不代表绑定已经最终生效。 -| 子命令 | 用法 | 说明 | -|--------|------|------| -| approve | `/naga approve ` | 通过绑定申请,系统会自动生成 Token 并通知申请人 | -| reject | `/naga reject ` | 拒绝绑定申请,申请人会收到私聊通知 | -| revoke | `/naga revoke ` | 吊销已有绑定,该 NagaAgent 将无法继续使用 | -| list | `/naga list` | 查看所有活跃的绑定(含使用次数) | -| pending | `/naga pending` | 查看等待审核的申请列表 | -| info | `/naga info ` | 查看指定绑定的详细信息(Token、使用次数、创建时间等) | +## /naga unbind -## 完整示例 +超级管理员执行: +```text +/naga unbind ``` -# 普通用户:在群聊中提交绑定申请 -/naga bind my-naga-001 -# 超级管理员:查看待审核列表 -/naga pending +行为: + +- 本地立即吊销该绑定 +- 尝试通知远端 Naga 端同步吊销 +- 绑定用户会收到一条私聊通知 -# 超级管理员:通过申请 -/naga approve my-naga-001 +## 常见问题 -# 超级管理员:查看绑定详情 -/naga info my-naga-001 +**Q: 在群里发 `/naga` 没反应?** -# 超级管理员:吊销绑定 -/naga revoke my-naga-001 -``` +A: 该群很可能不在 `naga.allowed_groups` 中;按设计这里会静默不可见。 -## 常见问题 +**Q: 为什么 `/naga bind` 提示成功了,但还不能用?** -**Q: 提示"Naga 集成未启用"?** -A: 请联系管理员开启相关配置开关。 +A: 因为它只是“已提交到 Naga 端”。真正生效要等远端回调确认。 -**Q: 在群里发了 /naga bind 没有任何反应?** -A: 该群可能不在白名单中,请联系管理员添加。 +**Q: 不配置 `models.naga` 可以吗?** -**Q: 绑定通过后 NagaAgent 怎么用?** -A: 请参考 NagaAgent 相关文档,填入QQ号以及其他几个参数即可完成对接。 \ No newline at end of file +A: 可以。未配置时,Naga 外发消息审核会回退到 `models.security`。 diff --git a/src/Undefined/skills/commands/naga/config.json b/src/Undefined/skills/commands/naga/config.json index 2f840a79..d9c039bc 100644 --- a/src/Undefined/skills/commands/naga/config.json +++ b/src/Undefined/skills/commands/naga/config.json @@ -1,7 +1,7 @@ { "name": "naga", - "description": "NagaAgent 联动命令", - "usage": "/naga <子命令> [参数]", + "description": "NagaAgent 绑定与解绑命令", + "usage": "/naga [参数]", "example": "/naga bind my-naga", "permission": "public", "allow_in_private": true, diff --git a/src/Undefined/skills/commands/naga/handler.py b/src/Undefined/skills/commands/naga/handler.py index 64007aa0..387c26ed 100644 --- a/src/Undefined/skills/commands/naga/handler.py +++ b/src/Undefined/skills/commands/naga/handler.py @@ -4,13 +4,16 @@ import json import logging from pathlib import Path +from typing import Literal from uuid import uuid4 from aiohttp import ClientSession, ClientTimeout -from Undefined.api.naga_store import mask_token +from Undefined.api.naga_store import NagaBinding, NagaStore, PendingBinding from Undefined.services.commands.context import CommandContext +from .policy import is_naga_command_visible + logger = logging.getLogger(__name__) _SCOPES_FILE = Path(__file__).parent / "scopes.json" @@ -38,20 +41,10 @@ async def _load_scopes() -> dict[str, str]: async 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 = await _load_scopes() raw = scopes.get(subcmd, "superadmin") scope = _SCOPE_ALIASES.get(raw, raw) - # 作用域限制 if scope == "group_only": if context.scope != "group": return "该子命令仅限群聊使用" @@ -60,8 +53,6 @@ async def _check_scope( if context.scope != "private": return "该子命令仅限私聊使用" return None - - # 权限检查 if scope == "public": return None if scope == "superadmin" and context.config.is_superadmin(sender_id): @@ -74,7 +65,6 @@ async def _check_scope( 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: @@ -82,14 +72,70 @@ async def _reply(context: CommandContext, text: str) -> None: 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) +def _naga_store(context: CommandContext) -> NagaStore | None: + store = getattr(context.dispatcher, "naga_store", None) + return store if isinstance(store, NagaStore) else None + + +def _remote_ready(context: CommandContext) -> str | None: + if not context.config.naga.api_url: + return "❌ naga.api_url 未配置" + if not context.config.naga.api_key: + return "❌ naga.api_key 未配置" + return None + + +async def _build_request_context( + naga_id: str, context: CommandContext +) -> dict[str, object]: + payload: dict[str, object] = { + "naga_id": naga_id, + "sender_id": context.sender_id, + "group_id": context.group_id, + "scope": context.scope, + "bot_qq": context.config.bot_qq, + } + + onebot = getattr(context, "onebot", None) + if onebot is None: + return payload + + try: + group_info = await onebot.get_group_info(context.group_id) + except Exception: + group_info = None + if isinstance(group_info, dict): + group_data = group_info.get("data", group_info) + if isinstance(group_data, dict): + group_name = str(group_data.get("group_name", "") or "").strip() + if group_name: + payload["group_name"] = group_name + + try: + user_info = await onebot.get_stranger_info(context.sender_id) + except Exception: + user_info = None + if isinstance(user_info, dict): + user_data = user_info.get("data", user_info) + if isinstance(user_data, dict): + nickname = str(user_data.get("nickname", "") or "").strip() + if nickname: + payload["sender_nickname"] = nickname + card = str(user_data.get("remark", "") or "").strip() + if card: + payload["sender_remark"] = card + + return payload + + async def execute(args: list[str], context: CommandContext) -> None: - """处理 /naga 命令""" - # 前置检查: 需同时开启 nagaagent_mode_enabled(总开关)和 naga.enabled(网关子开关) + if not is_naga_command_visible(context): + return + if not context.config.nagaagent_mode_enabled or not context.config.naga.enabled: await _reply(context, "Naga 集成未启用") return @@ -97,54 +143,36 @@ async def execute(args: list[str], context: CommandContext) -> None: if not args: await _reply( context, - "用法: /naga [参数]\n" + "用法: /naga [参数]\n" "子命令:\n" - " bind — 提交绑定申请(群聊内使用)\n" - " approve — 通过绑定申请\n" - " reject — 拒绝绑定申请\n" - " revoke — 吊销已有绑定\n" - " list — 列出所有活跃绑定\n" - " pending — 列出待审核申请\n" - " info — 查看绑定详情", + " bind — 在白名单群聊中发起绑定\n" + " unbind — 超管解绑并吊销签名", ) return subcmd = args[0].lower() sub_args = args[1:] - - # 权限检查 perm_err = await _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: + store = _naga_store(context) + if 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, + "unbind": _handle_unbind, } - 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] + await handler(sub_args, context, store) # type: ignore[operator] except Exception as exc: error_id = uuid4().hex[:8] logger.exception("[NagaCmd] %s 执行失败: error_id=%s", subcmd, error_id) @@ -152,330 +180,194 @@ async def execute(args: list[str], context: CommandContext) -> None: async def _handle_bind( - args: list[str], context: CommandContext, naga_store: object + args: list[str], context: CommandContext, naga_store: NagaStore ) -> 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 + remote_err = _remote_ready(context) + if remote_err is not None: + await _reply(context, remote_err) + return + naga_id = args[0].strip() if not naga_id: await _reply(context, "❌ naga_id 不能为空") return - ok, msg = await naga_store.submit_binding( + request_context = await _build_request_context(naga_id, context) + ok, msg, pending = await naga_store.submit_binding( naga_id=naga_id, qq_id=context.sender_id, group_id=context.group_id, + request_context=request_context, ) - - if not ok: + if not ok or pending is None: await _reply(context, f"❌ {msg}") return - await _reply(context, f"✅ {msg}") - - # 私聊通知超管 - superadmin_qq = context.config.superadmin_qq - if superadmin_qq: - try: - await _notify_user( - context, - 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 '失败(请手动同步)'}", + pending, should_submit = await naga_store.begin_remote_submit( + naga_id, + bind_uuid=pending.bind_uuid, ) + if pending is None: + await _reply(context, "❌ 绑定状态已变化,请重新发起 /naga bind") + return - # 私聊通知申请人(绕过代理,确保发给申请人而非调用者) - try: - await _notify_user( + is_existing = "已存在" in msg + if not should_submit: + prefix = "ℹ️" if is_existing else "✅" + await _reply( context, - binding.qq_id, - f"🎉 你的 Naga 绑定申请已通过!\nnaga_id: {naga_id}", + f"{prefix} {msg}\nnaga_id: {naga_id}\n绑定请求已在处理中,请等待远端确认", ) - 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 _notify_user( - context, - 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}' 已被吊销或不存在") + submit_status, detail = await _submit_bind_request_to_naga(context, pending) + if submit_status == "accepted": + verb = "已重新发送" if is_existing else "已发送" + await _reply( + context, + f"✅ {msg}\nnaga_id: {naga_id}\n绑定请求{verb}到 Naga 端,等待确认", + ) 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 '失败(请手动处理)'}", + "⚠️ 绑定申请已保留在本地,但未确认远端是否已接收\n" + f"naga_id: {naga_id}\n" + f"bind_uuid: {pending.bind_uuid}\n" + f"原因: {detail}\n" + "稍后重复执行同一个 /naga bind,会沿用这次申请继续重试", ) -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 +async def _handle_unbind( + args: list[str], context: CommandContext, naga_store: NagaStore ) -> 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 ") + await _reply(context, "用法: /naga unbind ") return naga_id = args[0].strip() - binding = naga_store.get_binding(naga_id) + binding, changed, err = await naga_store.revoke_binding(naga_id) if binding is None: - await _reply(context, f"❌ 未找到 naga_id '{naga_id}' 的绑定") + detail = ( + err.message if err is not None else f"未找到 naga_id '{naga_id}' 的绑定" + ) + await _reply(context, f"❌ {detail}") + return + if not changed: + 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 "从未使用" - ) - + remote_synced = await _notify_remote_revoke(context, binding) 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}", + f"✅ 已解绑 naga_id '{naga_id}'\n" + f"远端吊销同步: {'成功' if remote_synced else '失败(需远端手动处理)'}", ) + try: + await _notify_user( + context, + binding.qq_id, + f"🔒 你的 Naga 绑定已被解除\nnaga_id: {naga_id}", + ) + except Exception as exc: + logger.warning("[NagaCmd] 通知解绑失败: %s", exc) -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}" +async def _submit_bind_request_to_naga( + context: CommandContext, pending: PendingBinding +) -> tuple[Literal["accepted", "remote_error", "transport_error"], str]: + url = f"{context.config.naga.api_url.rstrip('/')}/api/integration/bind/request" + headers = { + "Authorization": f"Bearer {context.config.naga.api_key}", + "Content-Type": "application/json", + } + payload = { + "bind_uuid": pending.bind_uuid, + "naga_id": pending.naga_id, + "request_context": pending.request_context, + } 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: + async with session.post(url, json=payload, headers=headers) as resp: if resp.status < 300: logger.info( - "[NagaCmd] Token 同步成功: naga_id=%s status=%d", - naga_id, + "[NagaCmd] 绑定请求已提交: naga_id=%s bind_uuid=%s status=%d", + pending.naga_id, + pending.bind_uuid, resp.status, ) - return True + return "accepted", f"HTTP {resp.status}" body = await resp.text() logger.warning( - "[NagaCmd] Token 同步失败: naga_id=%s status=%d body=%s", - naga_id, + "[NagaCmd] 绑定请求失败: naga_id=%s bind_uuid=%s status=%d body=%s", + pending.naga_id, + pending.bind_uuid, resp.status, body[:200], ) - return False + detail = body[:200].strip() or f"HTTP {resp.status}" + return "remote_error", detail except Exception as exc: logger.warning( - "[NagaCmd] Token 同步请求失败: naga_id=%s error=%s", naga_id, exc + "[NagaCmd] 绑定请求异常: naga_id=%s bind_uuid=%s err=%s", + pending.naga_id, + pending.bind_uuid, + exc, ) - return False + return "transport_error", str(exc) or exc.__class__.__name__ -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 删除") +async def _notify_remote_revoke(context: CommandContext, binding: NagaBinding) -> bool: + remote_err = _remote_ready(context) + if remote_err is not None: + logger.warning("[NagaCmd] 远端吊销同步跳过: %s", remote_err) 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}" - + url = f"{context.config.naga.api_url.rstrip('/')}/api/integration/bind/revoke" + headers = { + "Authorization": f"Bearer {context.config.naga.api_key}", + "Content-Type": "application/json", + } + payload = { + "bind_uuid": binding.bind_uuid, + "naga_id": binding.naga_id, + } try: timeout = ClientTimeout(total=10) async with ClientSession(timeout=timeout) as session: - async with session.delete(url, headers=headers) as resp: + async with session.post(url, json=payload, headers=headers) as resp: if resp.status < 300: logger.info( - "[NagaCmd] Token 删除成功: naga_id=%s status=%d", - naga_id, + "[NagaCmd] 远端吊销同步成功: naga_id=%s bind_uuid=%s status=%d", + binding.naga_id, + binding.bind_uuid, resp.status, ) return True + body = await resp.text() logger.warning( - "[NagaCmd] Token 删除失败: naga_id=%s status=%d", - naga_id, + "[NagaCmd] 远端吊销同步失败: naga_id=%s bind_uuid=%s status=%d body=%s", + binding.naga_id, + binding.bind_uuid, resp.status, + body[:200], ) return False except Exception as exc: logger.warning( - "[NagaCmd] Token 删除请求失败: naga_id=%s error=%s", naga_id, exc + "[NagaCmd] 远端吊销同步异常: naga_id=%s bind_uuid=%s err=%s", + binding.naga_id, + binding.bind_uuid, + exc, ) return False diff --git a/src/Undefined/skills/commands/naga/policy.py b/src/Undefined/skills/commands/naga/policy.py new file mode 100644 index 00000000..d031248a --- /dev/null +++ b/src/Undefined/skills/commands/naga/policy.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from Undefined.services.commands.context import CommandContext + + +def is_naga_group_visible(context: CommandContext) -> bool: + if context.scope != "group": + return False + return int(context.group_id) in context.config.naga.allowed_groups + + +def is_naga_command_visible(context: CommandContext) -> bool: + api_cfg = getattr(context.config, "api", None) + if not bool(getattr(api_cfg, "enabled", False)): + return False + if not context.config.nagaagent_mode_enabled or not context.config.naga.enabled: + return False + if context.scope == "group": + return is_naga_group_visible(context) + if context.scope == "private": + return context.config.is_superadmin(context.sender_id) + return False + + +def is_command_visible(context: CommandContext) -> bool: + return is_naga_command_visible(context) diff --git a/src/Undefined/skills/commands/naga/scopes.json b/src/Undefined/skills/commands/naga/scopes.json index 0b0fb0d1..837e61a9 100644 --- a/src/Undefined/skills/commands/naga/scopes.json +++ b/src/Undefined/skills/commands/naga/scopes.json @@ -1,9 +1,4 @@ { "bind": "group_only", - "approve": "superadmin", - "reject": "superadmin", - "revoke": "superadmin", - "list": "superadmin", - "pending": "superadmin", - "info": "superadmin" + "unbind": "superadmin" } diff --git a/src/Undefined/utils/queue_intervals.py b/src/Undefined/utils/queue_intervals.py index ff2cdff5..976caad5 100644 --- a/src/Undefined/utils/queue_intervals.py +++ b/src/Undefined/utils/queue_intervals.py @@ -14,6 +14,10 @@ def build_model_queue_intervals(config: Config) -> dict[str, float]: config.security_model.model_name, config.security_model.queue_interval_seconds, ), + ( + config.naga_model.model_name, + config.naga_model.queue_interval_seconds, + ), ) intervals: dict[str, float] = {} for model_name, interval in pairs: diff --git a/src/Undefined/utils/tool_calls.py b/src/Undefined/utils/tool_calls.py index 07f57cad..e5ce2a36 100644 --- a/src/Undefined/utils/tool_calls.py +++ b/src/Undefined/utils/tool_calls.py @@ -202,3 +202,78 @@ def normalize_tool_arguments_json(raw_args: Any) -> str: return json.dumps({"_raw": raw_text}, **_JSON_DUMPS_KWARGS) return json.dumps({"_value": raw_args}, **_JSON_DUMPS_KWARGS) + + +def extract_required_tool_call_arguments( + response: dict[str, Any], + *, + expected_tool_name: str, + stage: str, + logger: logging.Logger | None = None, + error_context: str = "", +) -> dict[str, Any]: + """Extract arguments from the first required tool call in a model response.""" + context_suffix = f" {error_context}" if error_context else "" + choices = response.get("choices") + if not isinstance(choices, list) or not choices: + if logger: + logger.error("[工具错误] %s 响应缺少 choices%s", stage, context_suffix) + raise ValueError(f"{stage} 响应缺少 choices{context_suffix}") + + choice = choices[0] + if not isinstance(choice, dict): + if logger: + logger.error("[工具错误] %s choice 类型非法%s", stage, context_suffix) + raise ValueError(f"{stage} choice 类型非法{context_suffix}") + + message = choice.get("message") + if not isinstance(message, dict): + if logger: + logger.error("[工具错误] %s 响应缺少 message%s", stage, context_suffix) + raise ValueError(f"{stage} 响应缺少 message{context_suffix}") + + tool_calls = message.get("tool_calls") + if not isinstance(tool_calls, list) or not tool_calls: + if logger: + logger.error( + "[工具错误] %s 响应缺少 tool_calls%s content_preview=%s", + stage, + context_suffix, + format_log_payload(str(message.get("content", "")), max_length=200), + ) + raise ValueError(f"{stage} 响应缺少 tool_calls{context_suffix}") + + tool_call = tool_calls[0] + if not isinstance(tool_call, dict): + if logger: + logger.error("[工具错误] %s tool_call 类型非法%s", stage, context_suffix) + raise ValueError(f"{stage} tool_call 类型非法{context_suffix}") + + function = tool_call.get("function") + if not isinstance(function, dict): + if logger: + logger.error("[工具错误] %s function 缺失%s", stage, context_suffix) + raise ValueError(f"{stage} function 缺失{context_suffix}") + + tool_name = str(function.get("name", "")).strip() + if tool_name != expected_tool_name: + if logger: + logger.error( + "[工具错误] %s 工具名不匹配%s expected=%s actual=%s", + stage, + context_suffix, + expected_tool_name, + tool_name, + ) + raise ValueError(f"{stage} 工具名不匹配{context_suffix}: {tool_name}") + + parsed = parse_tool_arguments( + function.get("arguments"), + logger=logger, + tool_name=expected_tool_name, + ) + if not isinstance(parsed, dict): + if logger: + logger.error("[工具错误] %s 工具参数类型非法", stage) + raise ValueError(f"{stage} 工具参数类型非法") + return parsed diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index 2d4dc57c..9eb1e2f7 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -239,3 +239,22 @@ async def _process_job(self, job_id: str, job: dict[str, Any]) -> None: assert started[:2] == ["job-1", "job-2"] assert "job-1" in finished and "job-2" in finished + + +def test_extract_required_tool_args_preserves_job_context_in_error() -> None: + worker = _make_worker() + + with pytest.raises(ValueError) as exc_info: + worker._extract_required_tool_args( + {}, + expected_tool_name="submit_historian_result", + stage="historian_rewrite", + job_id="job-123", + attempt=2, + target="user:42", + ) + + message = str(exc_info.value) + assert "job_id=job-123" in message + assert "attempt=2" in message + assert "target=user:42" in message diff --git a/tests/test_command_help_reload.py b/tests/test_command_help_reload.py index fb59a44b..dcd094de 100644 --- a/tests/test_command_help_reload.py +++ b/tests/test_command_help_reload.py @@ -2,6 +2,7 @@ import asyncio import json +import time from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -53,6 +54,7 @@ def _write_command( handler_text: str = "v1", doc_text: str | None = None, allow_in_private: bool = False, + visibility_text: str | None = None, ) -> Path: command_dir = base_dir / command_dir_name command_dir.mkdir(parents=True, exist_ok=True) @@ -78,6 +80,10 @@ def _write_command( _write_handler(command_dir / "handler.py", handler_text) + if visibility_text is not None: + with open(command_dir / "policy.py", "w", encoding="utf-8") as f: + f.write(visibility_text) + if doc_text is not None: with open(command_dir / "README.md", "w", encoding="utf-8") as f: f.write(doc_text) @@ -131,6 +137,46 @@ async def test_command_registry_hot_reload_handler_update(tmp_path: Path) -> Non assert sender.messages[-1][1] == "v2" +def test_command_registry_hot_reload_policy_update(tmp_path: Path) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir(parents=True) + command_dir = _write_command( + commands_dir, + "gated", + command_name="gated", + usage="/gated", + handler_text="ok", + visibility_text=( + "from __future__ import annotations\n\n" + "from Undefined.services.commands.context import CommandContext\n\n" + "def is_command_visible(context: CommandContext) -> bool:\n" + " return False\n" + ), + ) + + registry = CommandRegistry(commands_dir) + registry.load_commands() + sender = _DummySender() + context = _build_context(registry, sender) + meta = registry.resolve("gated") + assert meta is not None + assert registry.is_visible(meta, context) is False + + time.sleep(0.25) + with open(command_dir / "policy.py", "w", encoding="utf-8") as f: + f.write( + "from __future__ import annotations\n\n" + "from Undefined.services.commands.context import CommandContext\n\n" + "def is_command_visible(context: CommandContext) -> bool:\n" + " return True\n" + ) + + assert registry.maybe_reload() is True + updated_meta = registry.resolve("gated") + assert updated_meta is not None + assert registry.is_visible(updated_meta, context) is True + + @pytest.mark.asyncio async def test_help_command_detail_includes_template_and_readme(tmp_path: Path) -> None: commands_dir = tmp_path / "commands" @@ -252,6 +298,47 @@ async def test_help_detail_hides_group_only_command_in_private_scope( assert "未找到命令" in output +@pytest.mark.asyncio +async def test_help_uses_command_visibility_policy(tmp_path: Path) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir(parents=True) + _write_command( + commands_dir, + "visible", + command_name="visible", + usage="/visible", + handler_text="ok", + ) + _write_command( + commands_dir, + "gated", + command_name="gated", + usage="/gated", + handler_text="ok", + visibility_text=( + "from __future__ import annotations\n\n" + "from Undefined.services.commands.context import CommandContext\n\n" + "def is_command_visible(context: CommandContext) -> bool:\n" + " return context.sender_id == 42\n" + ), + ) + + registry = CommandRegistry(commands_dir) + registry.load_commands() + sender = _DummySender() + context = _build_context(registry, sender) + + await help_execute([], context) + output = sender.messages[-1][1] + assert "/visible" in output + assert "/gated" not in output + + sender.messages.clear() + context.sender_id = 42 + await help_execute(["gated"], context) + assert "命令详情:/gated" in sender.messages[-1][1] + + @pytest.mark.asyncio async def test_dispatch_rejects_help_flag_with_new_help_style() -> None: sender = _DummySender() diff --git a/tests/test_config_hot_reload.py b/tests/test_config_hot_reload.py new file mode 100644 index 00000000..407e5134 --- /dev/null +++ b/tests/test_config_hot_reload.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +from Undefined.config.hot_reload import HotReloadContext, apply_config_updates + + +class _FakeSecurityService: + def __init__(self) -> None: + self.applied: list[Any] = [] + + def apply_config(self, config: Any) -> None: + self.applied.append(config) + + +class _FakeQueueManager: + def __init__(self) -> None: + self.intervals: list[Any] = [] + + def update_model_intervals(self, intervals: Any) -> None: + self.intervals.append(intervals) + + +def test_apply_config_updates_propagates_to_security_service() -> None: + updated = cast( + Any, + SimpleNamespace( + searxng_url="", + agent_intro_autogen_enabled=False, + agent_intro_autogen_queue_interval=60.0, + agent_intro_autogen_max_tokens=512, + agent_intro_hash_path="data/intro.json", + chat_model=SimpleNamespace( + model_name="chat", + queue_interval_seconds=1.0, + pool=SimpleNamespace(enabled=False), + ), + agent_model=SimpleNamespace( + model_name="agent", + queue_interval_seconds=1.0, + pool=SimpleNamespace(enabled=False), + ), + vision_model=SimpleNamespace( + model_name="vision", + queue_interval_seconds=1.0, + ), + security_model=SimpleNamespace( + model_name="security", + queue_interval_seconds=1.0, + ), + naga_model=SimpleNamespace( + model_name="naga", + queue_interval_seconds=1.0, + ), + ), + ) + security_service = _FakeSecurityService() + queue_manager = _FakeQueueManager() + context = HotReloadContext( + ai_client=cast(Any, SimpleNamespace()), + queue_manager=cast(Any, queue_manager), + config_manager=cast(Any, SimpleNamespace()), + security_service=cast(Any, security_service), + ) + + apply_config_updates( + updated, + {"naga_model.model_name": ("old", "new")}, + context, + ) + + assert security_service.applied == [updated] + assert len(queue_manager.intervals) == 1 diff --git a/tests/test_config_request_params.py b/tests/test_config_request_params.py index 4e059065..764503f8 100644 --- a/tests/test_config_request_params.py +++ b/tests/test_config_request_params.py @@ -148,6 +148,11 @@ def test_model_request_params_load_inherit_and_new_transport_fields( assert cfg.security_model.responses_force_stateless_replay is True assert cfg.security_model.request_params == cfg.chat_model.request_params + assert cfg.naga_model.api_mode == cfg.security_model.api_mode + assert cfg.naga_model.reasoning_enabled == cfg.security_model.reasoning_enabled + assert cfg.naga_model.reasoning_effort == cfg.security_model.reasoning_effort + assert cfg.naga_model.request_params == cfg.security_model.request_params + assert cfg.agent_model.api_mode == "responses" assert cfg.agent_model.reasoning_enabled is True assert cfg.agent_model.reasoning_effort == "minimal" @@ -172,3 +177,49 @@ def test_model_request_params_load_inherit_and_new_transport_fields( "metadata": {"source": "embed"}, } assert cfg.rerank_model.request_params == {"priority": "high"} + + +def test_naga_model_request_params_override_security_defaults(tmp_path: Path) -> None: + cfg = _load_config( + tmp_path / "config.toml", + """ +[onebot] +ws_url = "ws://127.0.0.1:3001" + +[models.chat] +api_url = "https://api.openai.com/v1" +api_key = "sk-chat" +model_name = "gpt-chat" + +[models.security] +api_url = "https://api.openai.com/v1" +api_key = "sk-security" +model_name = "gpt-security" +reasoning_enabled = true +reasoning_effort = "high" + +[models.security.request_params] +temperature = 0.1 + +[models.naga] +api_url = "https://api.openai.com/v1" +api_key = "sk-naga" +model_name = "gpt-naga" +api_mode = "responses" +reasoning_enabled = false +reasoning_effort = "low" + +[models.naga.request_params] +temperature = 0.6 +metadata = { source = "naga" } +""", + ) + + assert cfg.naga_model.model_name == "gpt-naga" + assert cfg.naga_model.api_mode == "responses" + assert cfg.naga_model.reasoning_enabled is False + assert cfg.naga_model.reasoning_effort == "low" + assert cfg.naga_model.request_params == { + "temperature": 0.6, + "metadata": {"source": "naga"}, + } diff --git a/tests/test_naga_command.py b/tests/test_naga_command.py new file mode 100644 index 00000000..6ec2c294 --- /dev/null +++ b/tests/test_naga_command.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from Undefined.api.naga_store import NagaStore +from Undefined.services.commands.context import CommandContext +from Undefined.services.commands.registry import CommandRegistry +from Undefined.skills.commands.help.handler import execute as help_execute +from Undefined.skills.commands.naga import handler as naga_handler + + +_COMMANDS_DIR = Path(__file__).resolve().parents[1] / "src/Undefined/skills/commands" + + +class _DummySender: + def __init__(self) -> None: + self.group_messages: list[tuple[int, str]] = [] + self.private_messages: list[tuple[int, str]] = [] + + async def send_group_message(self, group_id: int, message: str, **_: Any) -> None: + self.group_messages.append((group_id, message)) + + async def send_private_message(self, user_id: int, message: str, **_: Any) -> None: + self.private_messages.append((user_id, message)) + + +class _DummyOneBot: + async def get_group_info(self, group_id: int) -> dict[str, Any]: + return {"data": {"group_name": f"group-{group_id}"}} + + async def get_stranger_info(self, user_id: int) -> dict[str, Any]: + return {"data": {"nickname": f"user-{user_id}", "remark": ""}} + + +def _config( + *, + allowed_groups: set[int], + superadmin: bool = False, + api_enabled: bool = True, +) -> Any: + return SimpleNamespace( + api=SimpleNamespace(enabled=api_enabled), + nagaagent_mode_enabled=True, + naga=SimpleNamespace( + enabled=True, + allowed_groups=allowed_groups, + api_url="https://naga.example.com", + api_key="shared-key", + ), + bot_qq=42, + is_superadmin=lambda sender_id: superadmin and sender_id == 1, + is_admin=lambda sender_id: superadmin and sender_id == 1, + ) + + +def _context( + *, + sender: _DummySender, + registry: CommandRegistry, + scope: str, + group_id: int, + sender_id: int = 1, + user_id: int | None = None, + superadmin: bool = False, + allowed_groups: set[int] | None = None, + store: NagaStore | None = None, +) -> CommandContext: + allowed_groups = allowed_groups or set() + dispatcher = SimpleNamespace(sender=sender, naga_store=store) + return CommandContext( + group_id=group_id, + sender_id=sender_id, + config=cast(Any, _config(allowed_groups=allowed_groups, superadmin=superadmin)), + sender=cast(Any, sender), + ai=cast(Any, SimpleNamespace()), + faq_storage=cast(Any, SimpleNamespace()), + onebot=cast(Any, _DummyOneBot()), + security=cast(Any, SimpleNamespace()), + queue_manager=None, + rate_limiter=None, + dispatcher=cast(Any, dispatcher), + registry=registry, + scope=scope, + user_id=user_id, + ) + + +@pytest.fixture +def registry() -> CommandRegistry: + registry = CommandRegistry(_COMMANDS_DIR) + registry.load_commands() + return registry + + +@pytest.mark.asyncio +async def test_naga_hidden_from_help_in_non_allowlisted_group( + registry: CommandRegistry, +) -> None: + sender = _DummySender() + context = _context( + sender=sender, + registry=registry, + scope="group", + group_id=999, + allowed_groups={123}, + ) + + await help_execute([], context) + + assert sender.group_messages + output = sender.group_messages[-1][1] + assert "/naga <子命令>" not in output + + +@pytest.mark.asyncio +async def test_naga_visible_in_help_for_superadmin_private( + registry: CommandRegistry, +) -> None: + sender = _DummySender() + context = _context( + sender=sender, + registry=registry, + scope="private", + group_id=0, + user_id=1, + superadmin=True, + allowed_groups={123}, + ) + + await help_execute([], context) + + assert sender.group_messages + output = sender.group_messages[-1][1] + assert "/naga [参数]" in output + + +@pytest.mark.asyncio +async def test_naga_hidden_when_runtime_api_disabled( + registry: CommandRegistry, +) -> None: + sender = _DummySender() + context = _context( + sender=sender, + registry=registry, + scope="private", + group_id=0, + user_id=1, + superadmin=True, + allowed_groups={123}, + ) + context.config.api.enabled = False + + await help_execute([], context) + + assert sender.group_messages + output = sender.group_messages[-1][1] + assert "/naga [参数]" not in output + + +@pytest.mark.asyncio +async def test_naga_execute_silent_in_non_allowlisted_group( + registry: CommandRegistry, + tmp_path: Path, +) -> None: + sender = _DummySender() + store = NagaStore(tmp_path / "naga_bindings.json") + context = _context( + sender=sender, + registry=registry, + scope="group", + group_id=999, + allowed_groups={123}, + store=store, + ) + + await naga_handler.execute(["bind", "alice"], context) + + assert sender.group_messages == [] + assert sender.private_messages == [] + assert store.list_pending() == [] + + +@pytest.mark.asyncio +async def test_naga_bind_submits_pending_and_replies( + registry: CommandRegistry, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sender = _DummySender() + store = NagaStore(tmp_path / "naga_bindings.json") + context = _context( + sender=sender, + registry=registry, + scope="group", + group_id=123, + allowed_groups={123}, + store=store, + ) + + async def _accepted(*_: Any, **__: Any) -> tuple[str, str]: + return "accepted", "HTTP 202" + + monkeypatch.setattr(naga_handler, "_submit_bind_request_to_naga", _accepted) + + await naga_handler.execute(["bind", "alice"], context) + + pending = store.get_pending("alice") + assert pending is not None + assert sender.group_messages + assert "等待 Naga 端确认" in sender.group_messages[-1][1] + + +@pytest.mark.asyncio +async def test_naga_bind_reuses_existing_pending_without_duplicate_submit( + registry: CommandRegistry, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sender = _DummySender() + store = NagaStore(tmp_path / "naga_bindings.json") + context = _context( + sender=sender, + registry=registry, + scope="group", + group_id=123, + allowed_groups={123}, + store=store, + ) + calls = 0 + + async def _accepted(*_: Any, **__: Any) -> tuple[str, str]: + nonlocal calls + calls += 1 + return "accepted", "HTTP 202" + + monkeypatch.setattr(naga_handler, "_submit_bind_request_to_naga", _accepted) + + await naga_handler.execute(["bind", "alice"], context) + first_pending = store.get_pending("alice") + assert first_pending is not None + + await naga_handler.execute(["bind", "alice"], context) + second_pending = store.get_pending("alice") + assert second_pending is not None + assert second_pending.bind_uuid == first_pending.bind_uuid + assert calls == 1 + assert "已在处理中" in sender.group_messages[-1][1] + + +@pytest.mark.asyncio +async def test_naga_bind_keeps_pending_on_transport_error( + registry: CommandRegistry, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sender = _DummySender() + store = NagaStore(tmp_path / "naga_bindings.json") + context = _context( + sender=sender, + registry=registry, + scope="group", + group_id=123, + allowed_groups={123}, + store=store, + ) + + async def _transport_error(*_: Any, **__: Any) -> tuple[str, str]: + return "transport_error", "timeout" + + monkeypatch.setattr(naga_handler, "_submit_bind_request_to_naga", _transport_error) + + await naga_handler.execute(["bind", "alice"], context) + + pending = store.get_pending("alice") + assert pending is not None + assert "已保留在本地" in sender.group_messages[-1][1] + + +@pytest.mark.asyncio +async def test_naga_unbind_in_private_for_superadmin( + registry: CommandRegistry, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sender = _DummySender() + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=321, group_id=123, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + context = _context( + sender=sender, + registry=registry, + scope="private", + group_id=0, + user_id=1, + superadmin=True, + allowed_groups={123}, + store=store, + ) + + async def _synced(*_: Any, **__: Any) -> bool: + return True + + monkeypatch.setattr(naga_handler, "_notify_remote_revoke", _synced) + + await naga_handler.execute(["unbind", "alice"], context) + + binding = store.get_binding("alice") + assert binding is not None + assert binding.revoked is True + assert sender.private_messages + assert any("已解绑" in message for _, message in sender.private_messages) diff --git a/tests/test_naga_store.py b/tests/test_naga_store.py index 841f28d6..0aa51ec6 100644 --- a/tests/test_naga_store.py +++ b/tests/test_naga_store.py @@ -1,4 +1,4 @@ -"""NagaStore 单元测试""" +"""NagaStore 单元测试。""" from __future__ import annotations @@ -6,7 +6,14 @@ import pytest -from Undefined.api.naga_store import NagaStore, mask_token +from Undefined.api.naga_store import ( + CompletedBindRequest, + HistoricalBinding, + NagaStore, + PendingBinding, + generate_bind_uuid, + mask_token, +) @pytest.fixture @@ -14,58 +21,129 @@ 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) +async def test_submit_binding_creates_pending_with_bind_uuid(store: NagaStore) -> None: + ok, msg, pending = await store.submit_binding( + "alice", + qq_id=123, + group_id=456, + bind_uuid="uuid_a", + request_context={"group_name": "Test"}, + ) assert ok is True assert "已提交" in msg + assert pending is not None + assert pending.bind_uuid == "uuid_a" + assert pending.request_context["group_name"] == "Test" + + +async def test_submit_binding_generates_uuid_by_default(store: NagaStore) -> None: + ok, _, pending = await store.submit_binding("alice", qq_id=123, group_id=456) + assert ok is True + assert pending is not None + assert pending.bind_uuid 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) + ok, msg, pending = await store.submit_binding("alice", qq_id=789, group_id=456) assert ok is False - assert "审核队列" in msg + assert "处理中" in msg + assert pending is None 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) + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + binding, created, err = await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + assert binding is not None + assert created is True + assert err is None + + ok, msg, pending = await store.submit_binding("alice", qq_id=789, group_id=456) assert ok is False assert "已绑定" in msg + assert pending is None -async def test_approve(store: NagaStore) -> None: - await store.submit_binding("alice", qq_id=123, group_id=456) - binding = await store.approve("alice") +async def test_activate_binding(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + binding, created, err = await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) assert binding is not None + assert created is True + assert err is 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 + assert binding.bind_uuid == "uuid_a" + assert binding.delivery_signature == "sig_1" + assert store.list_pending() == [] -async def test_approve_nonexistent(store: NagaStore) -> None: - result = await store.approve("nonexistent") - assert result is None +async def test_activate_binding_is_idempotent(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + first, created, _ = await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + second, created2, err2 = await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + assert first is not None + assert second is not None + assert created is True + assert created2 is False + assert err2 is None + + +async def test_reject_binding(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + pending, removed, err = await store.reject_binding( + bind_uuid="uuid_a", + naga_id="alice", + ) + assert pending is not None + assert pending.qq_id == 123 + assert removed is True + assert err is None + assert store.list_pending() == [] -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_binding_is_idempotent(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.reject_binding(bind_uuid="uuid_a", naga_id="alice") + pending, removed, err = await store.reject_binding( + bind_uuid="uuid_a", + naga_id="alice", + ) -async def test_reject_nonexistent(store: NagaStore) -> None: - ok = await store.reject("nonexistent") - assert ok is False + assert pending is None + assert removed is False + assert err is None + + +async def test_cancel_pending(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + pending = await store.cancel_pending("alice", bind_uuid="uuid_a") + assert pending is not None + assert store.list_pending() == [] async def test_revoke(store: NagaStore) -> None: - await store.submit_binding("alice", qq_id=123, group_id=456) - await store.approve("alice") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) ok = await store.revoke("alice") assert ok is True binding = store.get_binding("alice") @@ -73,123 +151,266 @@ async def test_revoke(store: NagaStore) -> 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") +async def test_verify_delivery_valid(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + binding, err = store.verify_delivery( + naga_id="alice", + bind_uuid="uuid_a", + delivery_signature="sig_1", + ) 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 +async def test_verify_delivery_wrong_signature(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + binding, err = store.verify_delivery( + naga_id="alice", + bind_uuid="uuid_a", + delivery_signature="sig_wrong", + ) + assert binding is None + assert "delivery_signature" in err + + +async def test_verify_delivery_wrong_bind_uuid(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + binding, err = store.verify_delivery( + naga_id="alice", + bind_uuid="uuid_b", + delivery_signature="sig_1", + ) + assert binding is None + assert "bind_uuid" in err + + +async def test_verify_delivery_revoked(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) await store.revoke("alice") - valid, err = store.verify("alice", binding.token) - assert valid is False + binding, err = store.verify_delivery( + naga_id="alice", + bind_uuid="uuid_a", + delivery_signature="sig_1", + ) + assert binding is None 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") - + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + await store.record_usage("alice", bind_uuid="uuid_a") binding = store.get_binding("alice") assert binding is not None assert binding.use_count == 1 assert binding.last_used_at is not None + history = store.get_binding_history("uuid_a") + assert history is not None + assert history.use_count == 1 + + +async def test_record_usage_does_not_shift_to_new_generation(store: NagaStore) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + await store.revoke_binding( + "alice", + expected_bind_uuid="uuid_a", + delivery_signature="sig_1", + wait_for_delivery=False, + ) + await store.submit_binding("alice", qq_id=789, group_id=456, bind_uuid="uuid_b") + await store.activate_binding( + bind_uuid="uuid_b", + naga_id="alice", + delivery_signature="sig_2", + ) + + await store.record_usage("alice", bind_uuid="uuid_a") + + current = store.get_binding("alice") + assert current is not None + assert current.bind_uuid == "uuid_b" + assert current.use_count == 0 + history = store.get_binding_history("uuid_a") + assert history is not None + assert history.use_count == 1 + + +async def test_revoke_binding_with_old_bind_uuid_is_idempotent( + store: NagaStore, +) -> None: + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + await store.revoke_binding( + "alice", + expected_bind_uuid="uuid_a", + delivery_signature="sig_1", + wait_for_delivery=False, + ) + await store.submit_binding("alice", qq_id=789, group_id=456, bind_uuid="uuid_b") + await store.activate_binding( + bind_uuid="uuid_b", + naga_id="alice", + delivery_signature="sig_2", + ) + + historical, changed, err = await store.revoke_binding( + "alice", + expected_bind_uuid="uuid_a", + delivery_signature="sig_1", + wait_for_delivery=False, + ) + + assert historical is not None + assert historical.bind_uuid == "uuid_a" + assert historical.revoked is True + assert changed is False + assert err is None + current = store.get_binding("alice") + assert current is not None + assert current.bind_uuid == "uuid_b" + assert current.revoked is False + + +async def test_begin_remote_submit_persists_attempt_state(tmp_path: Path) -> None: + data_file = tmp_path / "naga_bindings.json" + store = NagaStore(data_file=data_file) + ok, _, pending = await store.submit_binding( + "alice", + qq_id=123, + group_id=456, + bind_uuid="uuid_a", + ) + assert ok is True + assert pending is not None + + updated, should_submit = await store.begin_remote_submit( + "alice", + bind_uuid="uuid_a", + ) + + assert should_submit is True + assert updated is not None + assert updated.submit_attempts == 1 + assert updated.last_submit_attempt_at is not None + + reloaded = NagaStore(data_file=data_file) + await reloaded.load() + reloaded_pending = reloaded.get_pending("alice") + assert reloaded_pending is not None + assert reloaded_pending.submit_attempts == 1 + assert reloaded_pending.last_submit_attempt_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") + await store1.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + binding, _, _ = await store1.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) assert binding is not None + await store1.submit_binding("bob", qq_id=789, group_id=456, bind_uuid="uuid_b") - 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] + reloaded = store2.get_binding("alice") + assert reloaded is not None + assert reloaded.delivery_signature == binding.delivery_signature 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.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) await store.revoke("alice") - ok, _ = await store.submit_binding("alice", qq_id=789, group_id=456) + ok, _, pending = await store.submit_binding("alice", qq_id=789, group_id=456) assert ok is True + assert pending is not None + + +async def test_submit_binding_also_prunes_terminal_records(store: NagaStore) -> None: + store._pending["expired"] = PendingBinding( + naga_id="expired", + bind_uuid="uuid_expired", + qq_id=1, + group_id=2, + requested_at=0, + ) + store._completed_requests["uuid_done"] = CompletedBindRequest( + naga_id="done", + bind_uuid="uuid_done", + qq_id=3, + group_id=4, + status="rejected", + resolved_at=0, + ) + store._history["uuid_old"] = HistoricalBinding( + naga_id="old", + bind_uuid="uuid_old", + delivery_signature="sig_old", + qq_id=5, + group_id=6, + created_at=0, + revoked=True, + revoked_at=0, + ) + + ok, _, pending = await store.submit_binding("alice", qq_id=123, group_id=456) + + assert ok is True + assert pending is not None + assert "expired" not in store._pending + assert "uuid_done" not in store._completed_requests + assert "uuid_old" not in store._history + + +def test_generate_bind_uuid() -> None: + first = generate_bind_uuid() + second = generate_bind_uuid() + assert first + assert second + assert first != second def test_mask_token() -> None: - assert mask_token("udf_a1b2c3d4e5f6g7h8") == "udf_a1b2c3d4..." + assert mask_token("sig_a1b2c3d4e5f6g7h8") == "sig_a1b2c3d4..." assert mask_token("short") == "short" - assert mask_token("udf_12345678") == "udf_12345678" - assert mask_token("udf_123456789") == "udf_12345678..." diff --git a/tests/test_runtime_api_naga.py b/tests/test_runtime_api_naga.py new file mode 100644 index 00000000..e3867934 --- /dev/null +++ b/tests/test_runtime_api_naga.py @@ -0,0 +1,820 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +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.naga_store import NagaStore +from Undefined.services.security import NagaModerationResult + + +def _json(response: Response) -> Any: + assert response.text is not None + return json.loads(response.text) + + +class _FakeSender: + def __init__(self) -> None: + self.private_messages: list[tuple[int, str]] = [] + self.group_messages: list[tuple[int, str]] = [] + + async def send_private_message(self, user_id: int, message: str, **_: Any) -> None: + self.private_messages.append((user_id, message)) + + async def send_group_message(self, group_id: int, message: str, **_: Any) -> None: + self.group_messages.append((group_id, message)) + + +class _FakeSecurity: + def __init__(self, result: NagaModerationResult) -> None: + self._result = result + + async def moderate_naga_message( + self, *, message_format: str, content: str + ) -> NagaModerationResult: + _ = message_format, content + return self._result + + +def _make_request( + *, + json_body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> web.Request: + ns = SimpleNamespace( + query={}, + headers=headers or {}, + remote="127.0.0.1", + scheme="http", + host="127.0.0.1:8788", + ) + if json_body is not None: + + async def _json_body() -> dict[str, Any]: + return json_body + + ns.json = _json_body + return cast(web.Request, cast(Any, ns)) + + +def _make_server( + *, + store: NagaStore, + sender: _FakeSender, + security_result: NagaModerationResult | None = None, + security: Any | None = None, +) -> RuntimeAPIServer: + security_impl = ( + security + if security is not None + else ( + _FakeSecurity(security_result) + if security_result is not None + else SimpleNamespace() + ) + ) + cfg = SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="testkey", + openapi_enabled=True, + ), + nagaagent_mode_enabled=True, + naga=SimpleNamespace( + enabled=True, + api_key="shared-key", + allowed_groups={456}, + ), + naga_model=SimpleNamespace( + model_name="naga-moderation", + api_url="https://api.example.com/v1", + api_key="sk-naga", + ), + ) + context = RuntimeAPIContext( + config_getter=lambda: cfg, + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=None), + command_dispatcher=SimpleNamespace(security=security_impl), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(), + sender=sender, + naga_store=store, + ) + return RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + +@pytest.mark.asyncio +async def test_naga_bind_callback_activates_pending_binding(tmp_path: Path) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + sender = _FakeSender() + server = _make_server(store=store, sender=sender) + + response = await server._naga_bind_callback_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "status": "approved", + "delivery_signature": "sig_1", + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 200 + assert payload["ok"] is True + binding = store.get_binding("alice") + assert binding is not None + assert binding.delivery_signature == "sig_1" + assert sender.private_messages + + +@pytest.mark.asyncio +async def test_naga_bind_callback_reject_is_idempotent(tmp_path: Path) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + sender = _FakeSender() + server = _make_server(store=store, sender=sender) + request = _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "status": "rejected", + "reason": "duplicate", + }, + headers={"Authorization": "Bearer shared-key"}, + ) + + first = await server._naga_bind_callback_handler(request) + second = await server._naga_bind_callback_handler(request) + + assert first.status == 200 + payload = _json(second) + assert second.status == 200 + assert payload["idempotent"] is True + + +@pytest.mark.asyncio +async def test_naga_bind_callback_reject_returns_409_after_approval( + tmp_path: Path, +) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server(store=store, sender=sender) + + response = await server._naga_bind_callback_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "status": "rejected", + "reason": "late reject", + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 409 + assert "already approved" in payload["error"] + + +@pytest.mark.asyncio +async def test_naga_bind_callback_approved_returns_404_when_pending_missing( + tmp_path: Path, +) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + sender = _FakeSender() + server = _make_server(store=store, sender=sender) + + response = await server._naga_bind_callback_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "status": "approved", + "delivery_signature": "sig_1", + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 404 + assert "未处于待绑定状态" in payload["error"] + + +@pytest.mark.asyncio +async def test_naga_bind_callback_approved_returns_403_on_signature_mismatch( + tmp_path: Path, +) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server(store=store, sender=sender) + + response = await server._naga_bind_callback_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "status": "approved", + "delivery_signature": "sig_wrong", + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 403 + assert "delivery_signature 不匹配" in payload["error"] + + +@pytest.mark.asyncio +async def test_naga_messages_send_rejects_target_mismatch(tmp_path: Path) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server( + store=store, + sender=sender, + security_result=NagaModerationResult( + blocked=False, + status="passed", + categories=[], + message="ok", + model_name="naga-moderation", + ), + ) + + response = await server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 999, "group_id": 456, "mode": "private"}, + "message": {"format": "text", "content": "hello"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + assert response.status == 403 + payload = _json(response) + assert "target does not match" in payload["error"] + assert store._active_deliveries == {} + + +@pytest.mark.asyncio +async def test_naga_messages_send_releases_delivery_when_group_not_allowed( + tmp_path: Path, +) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server( + store=store, + sender=sender, + security_result=NagaModerationResult( + blocked=False, + status="passed", + categories=[], + message="ok", + model_name="naga-moderation", + ), + ) + server._ctx.config_getter().naga.allowed_groups = set() + + response = await server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 123, "group_id": 456, "mode": "group"}, + "message": {"format": "text", "content": "hello"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + assert response.status == 403 + assert store._active_deliveries == {} + + +@pytest.mark.asyncio +async def test_naga_messages_send_both_mode_allows_private_when_group_not_allowed( + tmp_path: Path, +) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server( + store=store, + sender=sender, + security_result=NagaModerationResult( + blocked=False, + status="passed", + categories=[], + message="ok", + model_name="naga-moderation", + ), + ) + server._ctx.config_getter().naga.allowed_groups = set() + + response = await server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 123, "group_id": 456, "mode": "both"}, + "message": {"format": "text", "content": "hello"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 200 + assert payload["ok"] is True + assert payload["sent_private"] is True + assert payload["sent_group"] is False + assert payload["partial_success"] is True + assert payload["delivery_status"] == "partial_success" + assert sender.private_messages == [(123, "hello")] + assert sender.group_messages == [] + assert store._active_deliveries == {} + + +@pytest.mark.asyncio +async def test_naga_messages_send_blocks_on_moderation_hit(tmp_path: Path) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server( + store=store, + sender=sender, + security_result=NagaModerationResult( + blocked=True, + status="blocked", + categories=["personal_privacy"], + message="contains privacy leak", + model_name="naga-moderation", + ), + ) + + response = await server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 123, "group_id": 456, "mode": "private"}, + "message": {"format": "text", "content": "secret"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + assert response.status == 403 + payload = _json(response) + assert payload["ok"] is False + assert payload["moderation"]["status"] == "blocked" + assert sender.private_messages == [] + + +@pytest.mark.asyncio +async def test_naga_messages_send_allows_render_with_fallback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server( + store=store, + sender=sender, + security_result=NagaModerationResult( + blocked=False, + status="error_allowed", + categories=[], + message="moderation timeout", + model_name="naga-moderation", + ), + ) + + async def _render_markdown_to_html(_: str) -> str: + return "

hello

" + + async def _render_html_to_image(_: str, __: str) -> None: + raise RuntimeError("render failed") + + monkeypatch.setattr( + "Undefined.api.app.render_markdown_to_html", _render_markdown_to_html + ) + monkeypatch.setattr("Undefined.api.app.render_html_to_image", _render_html_to_image) + + response = await server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 123, "group_id": 456, "mode": "both"}, + "message": {"format": "markdown", "content": "# hello"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 200 + assert payload["ok"] is True + assert payload["render_fallback"] is True + assert payload["partial_success"] is False + assert payload["delivery_status"] == "full_success" + assert payload["moderation"]["status"] == "error_allowed" + assert sender.private_messages + assert sender.group_messages + + +@pytest.mark.asyncio +async def test_naga_messages_send_falls_back_when_markdown_conversion_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server( + store=store, + sender=sender, + security_result=NagaModerationResult( + blocked=False, + status="error_allowed", + categories=[], + message="moderation timeout", + model_name="naga-moderation", + ), + ) + + async def _render_markdown_to_html(_: str) -> str: + raise RuntimeError("markdown failed") + + monkeypatch.setattr( + "Undefined.api.app.render_markdown_to_html", _render_markdown_to_html + ) + + response = await server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 123, "group_id": 456, "mode": "private"}, + "message": {"format": "markdown", "content": "# hello"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 200 + assert payload["ok"] is True + assert payload["render_fallback"] is True + assert payload["partial_success"] is False + assert payload["delivery_status"] == "full_success" + assert sender.private_messages == [(123, "# hello")] + + +@pytest.mark.asyncio +async def test_naga_messages_send_both_mode_reports_partial_success( + tmp_path: Path, +) -> None: + class _PartiallyFailingSender(_FakeSender): + async def send_group_message( + self, group_id: int, message: str, **_: Any + ) -> None: + del group_id, message + raise RuntimeError("group failed") + + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _PartiallyFailingSender() + server = _make_server( + store=store, + sender=sender, + security_result=NagaModerationResult( + blocked=False, + status="passed", + categories=[], + message="ok", + model_name="naga-moderation", + ), + ) + + response = await server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 123, "group_id": 456, "mode": "both"}, + "message": {"format": "text", "content": "hello"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 200 + assert payload["ok"] is True + assert payload["sent_private"] is True + assert payload["sent_group"] is False + assert payload["partial_success"] is True + assert payload["delivery_status"] == "partial_success" + assert sender.private_messages == [(123, "hello")] + assert sender.group_messages == [] + + +@pytest.mark.asyncio +async def test_naga_messages_send_both_mode_returns_403_if_group_blocked_and_private_fails( + tmp_path: Path, +) -> None: + class _PrivateFailingSender(_FakeSender): + async def send_private_message( + self, user_id: int, message: str, **_: Any + ) -> None: + del user_id, message + raise RuntimeError("private failed") + + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _PrivateFailingSender() + server = _make_server( + store=store, + sender=sender, + security_result=NagaModerationResult( + blocked=False, + status="passed", + categories=[], + message="ok", + model_name="naga-moderation", + ), + ) + server._ctx.config_getter().naga.allowed_groups = set() + + response = await server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 123, "group_id": 456, "mode": "both"}, + "message": {"format": "text", "content": "hello"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 403 + assert payload["error"] == "bound group is not in naga.allowed_groups" + assert payload["sent_private"] is False + assert payload["sent_group"] is False + + +@pytest.mark.asyncio +async def test_naga_messages_send_aborts_when_revoked_mid_flight( + tmp_path: Path, +) -> None: + class _BlockingSecurity: + def __init__(self) -> None: + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def moderate_naga_message( + self, *, message_format: str, content: str + ) -> NagaModerationResult: + _ = message_format, content + self.started.set() + await self.release.wait() + return NagaModerationResult( + blocked=False, + status="passed", + categories=[], + message="ok", + model_name="naga-moderation", + ) + + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + security = _BlockingSecurity() + server = _make_server(store=store, sender=sender, security=security) + + send_task = asyncio.create_task( + server._naga_messages_send_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + "target": {"qq_id": 123, "group_id": 456, "mode": "both"}, + "message": {"format": "text", "content": "hello"}, + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + ) + await security.started.wait() + + unbind_task = asyncio.create_task( + server._naga_unbind_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + ) + + security.release.set() + send_response = await send_task + unbind_response = await unbind_task + + send_payload = _json(send_response) + unbind_payload = _json(unbind_response) + assert send_response.status == 409 + assert "吊销" in send_payload["error"] + assert sender.private_messages == [] + assert sender.group_messages == [] + assert unbind_response.status == 200 + assert unbind_payload["idempotent"] is False + + +@pytest.mark.asyncio +async def test_naga_unbind_handler_revokes_binding(tmp_path: Path) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server(store=store, sender=sender) + + response = await server._naga_unbind_handler( + _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + }, + headers={"Authorization": "Bearer shared-key"}, + ) + ) + + payload = _json(response) + assert response.status == 200 + assert payload["ok"] is True + binding = store.get_binding("alice") + assert binding is not None + assert binding.revoked is True + + +@pytest.mark.asyncio +async def test_naga_unbind_handler_old_generation_is_idempotent_after_rebind( + tmp_path: Path, +) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + await store.submit_binding("alice", qq_id=123, group_id=456, bind_uuid="uuid_a") + await store.activate_binding( + bind_uuid="uuid_a", + naga_id="alice", + delivery_signature="sig_1", + ) + sender = _FakeSender() + server = _make_server(store=store, sender=sender) + old_request = _make_request( + json_body={ + "bind_uuid": "uuid_a", + "naga_id": "alice", + "delivery_signature": "sig_1", + }, + headers={"Authorization": "Bearer shared-key"}, + ) + + first = await server._naga_unbind_handler(old_request) + assert first.status == 200 + + await store.submit_binding("alice", qq_id=789, group_id=456, bind_uuid="uuid_b") + await store.activate_binding( + bind_uuid="uuid_b", + naga_id="alice", + delivery_signature="sig_2", + ) + + second = await server._naga_unbind_handler(old_request) + + payload = _json(second) + current = store.get_binding("alice") + assert second.status == 200 + assert payload["idempotent"] is True + assert current is not None + assert current.bind_uuid == "uuid_b" + assert current.revoked is False + + +@pytest.mark.asyncio +async def test_openapi_only_exposes_enabled_naga_routes(tmp_path: Path) -> None: + store = NagaStore(tmp_path / "naga_bindings.json") + sender = _FakeSender() + server = _make_server(store=store, sender=sender) + + response = await server._openapi_handler(_make_request()) + payload = _json(response) + assert "/api/v1/naga/messages/send" in payload["paths"] + assert payload["paths"]["/api/v1/naga/messages/send"]["post"]["security"] == [ + {"BearerAuth": []} + ] + + cfg = server._ctx.config_getter() + cfg.naga.enabled = False + response_disabled = await server._openapi_handler(_make_request()) + payload_disabled = _json(response_disabled) + assert "/api/v1/naga/messages/send" not in payload_disabled["paths"] diff --git a/tests/test_runtime_api_probes.py b/tests/test_runtime_api_probes.py index 6e48a663..ce60ce94 100644 --- a/tests/test_runtime_api_probes.py +++ b/tests/test_runtime_api_probes.py @@ -8,6 +8,7 @@ from aiohttp import web from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api import app as runtime_api_app @pytest.mark.asyncio @@ -67,3 +68,104 @@ async def test_runtime_internal_probe_includes_chat_model_transport_fields() -> "model_name": "text-embedding-3-small", "api_url": "https://api.example.com/...", } + + +@pytest.mark.asyncio +async def test_runtime_external_probe_skips_naga_model_when_integration_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_probe_http_endpoint(**kwargs: Any) -> dict[str, Any]: + return { + "name": kwargs["name"], + "status": "ok", + "url": "https://api.example.com/...", + "http_status": 200, + "latency_ms": 1.0, + "model_name": kwargs.get("model_name", ""), + } + + async def _fake_probe_ws_endpoint(_: str) -> dict[str, Any]: + return { + "name": "onebot_ws", + "status": "ok", + "host": "127.0.0.1", + "port": 3001, + "latency_ms": 1.0, + } + + monkeypatch.setattr( + runtime_api_app, "_probe_http_endpoint", _fake_probe_http_endpoint + ) + monkeypatch.setattr(runtime_api_app, "_probe_ws_endpoint", _fake_probe_ws_endpoint) + + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + nagaagent_mode_enabled=False, + naga=SimpleNamespace(enabled=False), + chat_model=SimpleNamespace( + model_name="chat", + api_url="https://api.example.com/v1", + api_key="k1", + ), + vision_model=SimpleNamespace( + model_name="vision", + api_url="https://api.example.com/v1", + api_key="k2", + ), + security_model=SimpleNamespace( + model_name="security", + api_url="https://api.example.com/v1", + api_key="k3", + ), + naga_model=SimpleNamespace( + model_name="naga", + api_url="https://api.example.com/v1", + api_key="k4", + ), + agent_model=SimpleNamespace( + model_name="agent", + api_url="https://api.example.com/v1", + api_key="k5", + ), + embedding_model=SimpleNamespace( + model_name="embed", + api_url="https://api.example.com/v1", + api_key="k6", + ), + rerank_model=SimpleNamespace( + model_name="rerank", + api_url="https://api.example.com/v1", + api_key="k7", + ), + onebot_ws_url="ws://127.0.0.1:3001", + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=None), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(), + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + request = cast(web.Request, cast(Any, SimpleNamespace())) + response = await server._external_probe_handler(request) + response_text = response.text + assert response_text is not None + payload = json.loads(response_text) + + naga_probe = next( + item for item in payload["results"] if item["name"] == "naga_model" + ) + assert naga_probe == { + "name": "naga_model", + "status": "skipped", + "reason": "naga_integration_disabled", + "model_name": "naga", + } diff --git a/tests/test_security_naga_moderation.py b/tests/test_security_naga_moderation.py new file mode 100644 index 00000000..33d21fe4 --- /dev/null +++ b/tests/test_security_naga_moderation.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from Undefined.services.security import SecurityService + + +class _FakeRequester: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def request(self, **kwargs: Any) -> dict[str, Any]: + self.calls.append(kwargs) + return { + "choices": [ + { + "message": { + "tool_calls": [ + { + "function": { + "name": "submit_naga_moderation_result", + "arguments": ( + '{"decision":"block",' + '"categories":["personal_privacy"],' + '"reason":"contains privacy"}' + ), + } + } + ] + } + } + ] + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api_mode", ["chat_completions", "responses"]) +async def test_moderate_naga_message_uses_tool_call_for_both_api_modes( + api_mode: str, +) -> None: + service = object.__new__(SecurityService) + requester = _FakeRequester() + model_config = SimpleNamespace( + api_mode=api_mode, + thinking_enabled=False, + model_name="naga-model", + ) + service.config = cast( + Any, + SimpleNamespace( + naga_model=model_config, + security_model=model_config, + ), + ) + service._requester = cast(Any, requester) + + result = await service.moderate_naga_message( + message_format="markdown", + content="# hello", + ) + + assert result.blocked is True + assert result.status == "blocked" + assert result.categories == ["personal_privacy"] + assert requester.calls + call = requester.calls[-1] + assert call["tools"][0]["function"]["name"] == "submit_naga_moderation_result" + assert call["tool_choice"] == { + "type": "function", + "function": {"name": "submit_naga_moderation_result"}, + } + + +@pytest.mark.asyncio +async def test_moderate_naga_message_returns_error_allowed_when_tool_call_missing() -> ( + None +): + class _BrokenRequester: + async def request(self, **kwargs: Any) -> dict[str, Any]: + _ = kwargs + return {"choices": [{"message": {"content": "not structured"}}]} + + service = object.__new__(SecurityService) + model_config = SimpleNamespace( + api_mode="responses", + thinking_enabled=False, + model_name="naga-model", + ) + service.config = cast( + Any, + SimpleNamespace( + naga_model=model_config, + security_model=model_config, + ), + ) + service._requester = cast(Any, _BrokenRequester()) + + result = await service.moderate_naga_message( + message_format="text", + content="hello", + ) + + assert result.blocked is False + assert result.status == "error_allowed" diff --git a/uv.lock b/uv.lock index 019689b6..bbd265fa 100644 --- a/uv.lock +++ b/uv.lock @@ -4671,7 +4671,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.2.3" +version = "3.2.4" source = { editable = "." } dependencies = [ { name = "aiofiles" },