From 0820fdb59760a376ca41b61fcdc41ea8ec27b427 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Tue, 14 Jul 2026 19:20:51 +0800 Subject: [PATCH 01/17] feat(weixin): add ClawBot iLink private chat bridge Add canonical delivery addresses, logical QQ identity binding, secure local state, Runtime/Management APIs, scheduling support, responsive WebUI management, documentation, and regression coverage. Co-authored-by: GPT-5 Codex --- ARCHITECTURE.md | 15 +- README.md | 6 +- config.toml.example | 18 + docs/access-control.md | 4 +- docs/configuration.md | 47 +- docs/management-api.md | 14 + docs/message-batching.md | 2 +- docs/openapi.md | 21 +- docs/pipelines.md | 3 +- docs/usage.md | 2 + docs/webui-guide.md | 16 +- docs/wechat-ilink.md | 137 +++ pyproject.toml | 2 + src/Undefined/api/_context.py | 1 + src/Undefined/api/_openapi.py | 39 + src/Undefined/api/app.py | 76 ++ src/Undefined/api/routes/schedules.py | 38 +- src/Undefined/api/routes/weixin.py | 285 ++++++ src/Undefined/attachments/render.py | 11 +- src/Undefined/config/__init__.py | 2 + src/Undefined/config/config_class.py | 2 + src/Undefined/config/domain_parsers.py | 37 + src/Undefined/config/load_sections/domains.py | 3 + src/Undefined/config/models.py | 18 + src/Undefined/handlers/auto_extract.py | 24 +- src/Undefined/handlers/message_flow.py | 99 ++ src/Undefined/main.py | 21 +- src/Undefined/memes/search.py | 27 +- src/Undefined/memes/service.py | 27 +- src/Undefined/scheduled_task_storage.py | 1 + .../services/coordinator/batching.py | 3 + src/Undefined/services/coordinator/group.py | 20 +- src/Undefined/services/coordinator/private.py | 142 ++- .../services/message_batcher/state.py | 4 +- src/Undefined/services/model_pool.py | 27 +- .../skills/pipelines/arxiv/handler.py | 6 +- .../skills/pipelines/bilibili/handler.py | 6 +- src/Undefined/skills/pipelines/context.py | 10 +- .../skills/pipelines/douyin/handler.py | 6 +- .../skills/pipelines/github/handler.py | 6 +- .../messages/send_message/config.json | 7 +- .../toolsets/messages/send_message/handler.py | 74 +- .../messages/send_private_message/config.json | 7 +- .../messages/send_private_message/handler.py | 60 +- .../skills/toolsets/scheduler/README.md | 2 + .../create_schedule_task/config.json | 4 + .../scheduler/create_schedule_task/handler.py | 3 + .../scheduler/list_schedule_tasks/handler.py | 5 + .../update_schedule_task/config.json | 4 + .../scheduler/update_schedule_task/handler.py | 4 + src/Undefined/utils/history.py | 7 +- src/Undefined/utils/message_targets.py | 203 ++-- src/Undefined/utils/scheduler.py | 175 +++- src/Undefined/utils/sender.py | 364 +++++++- src/Undefined/utils/xml.py | 24 +- src/Undefined/webui/routes/__init__.py | 12 +- src/Undefined/webui/routes/_weixin.py | 165 ++++ src/Undefined/webui/static/css/components.css | 335 +++++++ src/Undefined/webui/static/css/responsive.css | 88 ++ src/Undefined/webui/static/js/i18n.js | 167 ++++ src/Undefined/webui/static/js/main.js | 26 +- src/Undefined/webui/static/js/schedules.js | 26 +- src/Undefined/webui/static/js/ui.js | 6 + src/Undefined/webui/static/js/weixin.js | 881 ++++++++++++++++++ src/Undefined/webui/templates/index.html | 152 ++- src/Undefined/weixin/__init__.py | 18 + src/Undefined/weixin/models.py | 175 ++++ src/Undefined/weixin/service.py | 875 +++++++++++++++++ src/Undefined/weixin/store.py | 412 ++++++++ tests/test_handlers_pipelines.py | 39 + tests/test_memes.py | 18 + tests/test_message_targets.py | 60 +- tests/test_runtime_api_schedules.py | 50 + tests/test_runtime_api_weixin.py | 127 +++ tests/test_scheduled_task_unit.py | 2 + tests/test_scheduler_self_instruction.py | 70 ++ tests/test_sender.py | 88 ++ tests/test_weixin_config.py | 34 + tests/test_weixin_service.py | 274 ++++++ tests/test_weixin_store.py | 101 ++ uv.lock | 30 + 81 files changed, 6173 insertions(+), 229 deletions(-) create mode 100644 docs/wechat-ilink.md create mode 100644 src/Undefined/api/routes/weixin.py create mode 100644 src/Undefined/webui/routes/_weixin.py create mode 100644 src/Undefined/webui/static/js/weixin.js create mode 100644 src/Undefined/weixin/__init__.py create mode 100644 src/Undefined/weixin/models.py create mode 100644 src/Undefined/weixin/service.py create mode 100644 src/Undefined/weixin/store.py create mode 100644 tests/test_runtime_api_weixin.py create mode 100644 tests/test_weixin_config.py create mode 100644 tests/test_weixin_service.py create mode 100644 tests/test_weixin_store.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5397cff6..6dfd95c5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -850,20 +850,25 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 | **思考链** | `*.thinking_enabled` | 思维链支持 | | **思维链兼容** | `*.thinking_tool_call_compat` | 思维链 + 工具调用兼容 | | **WebUI** | `webui.url`, `webui.port`, `webui.password` | 配置控制台 | +| **微信 iLink** | `weixin.enabled`, `weixin.state_dir`, `weixin.*_seconds` | 微信私聊帐号生命周期、重试与本地状态 | ## 十、架构详解 ### 8层架构分层 1. **外部实体层**:用户、管理员、OneBot 协议端 (NapCat/Lagrange.Core)、大模型 API 服务商 -2. **核心入口层**:main.py 启动入口、配置管理器 (config/loader.py + parsers/ + load_sections/)、热更新应用器 (config/hot_reload.py)、OneBotClient (onebot/ + onebot.py shim)、RequestContext (context.py)、Runtime API Server (api/app.py → api/routes/ 路由子模块,含 naga/ 子包) -3. **消息处理层**:MessageHandler (`handlers/`)、SecurityService (security.py)、CommandDispatcher (services/command.py + commands/ mixins)、MessageBatcher (services/message_batcher/)、AICoordinator (services/coordinator/ + ai_coordinator.py 门面)、QueueManager (queue_manager.py)、自动处理管线 (skills/pipelines/)、Bilibili/arXiv/GitHub 解析与发送模块 +2. **核心入口层**:main.py 启动入口、配置管理器 (config/loader.py + parsers/ + load_sections/)、热更新应用器 (config/hot_reload.py)、OneBotClient (onebot/ + onebot.py shim)、WeixinService (`weixin/` + `weixin-ilink-client`)、RequestContext (context.py)、Runtime API Server (api/app.py → api/routes/ 路由子模块,含 naga/ 子包) +3. **消息处理层**:MessageHandler (`handlers/`)、统一 DeliveryAddress 路由 (`utils/message_targets.py`)、SecurityService (security.py)、CommandDispatcher (services/command.py + commands/ mixins)、MessageBatcher (services/message_batcher/)、AICoordinator (services/coordinator/ + ai_coordinator.py 门面)、QueueManager (queue_manager.py)、自动处理管线 (skills/pipelines/)、Bilibili/arXiv/GitHub 解析与发送模块 自动提取由 `PipelineRegistry` 并行检测、并行处理全部命中的管线;发送结果写入历史后继续进入 AI 自动回复。 4. **AI 核心能力层**:AIClient (ai/client/ + client.py shim)、PromptBuilder (ai/prompts/ + prompts.py shim)、ModelRequester (ai/llm/ + llm.py shim)、ToolManager (tooling.py)、MultimodalAnalyzer (ai/multimodal/ + multimodal.py shim)、SummaryService (summaries.py)、TokenCounter (tokens.py) 5. **存储与上下文层**:MessageHistoryManager (utils/history.py, 10000条限制)、MemoryStorage (memory.py, 置顶备忘录, 500条上限)、EndSummaryStorage、CognitiveService + JobQueue + HistorianWorker + VectorStore + ProfileStorage、MemeService + MemeWorker + MemeStore + MemeVectorStore (表情包库)、FAQStorage、ScheduledTaskStorage、TokenUsageStorage (自动归档) 6. **技能系统层**:ToolRegistry (registry.py)、AgentRegistry、7个 Agents、11类 Toolsets 7. **异步 IO 层**:统一 IO 工具 (utils/io.py),包含 write_json、read_json、append_line、跨平台文件锁 (flock/msvcrt) -8. **数据持久化层**:历史数据目录、FAQ 目录、Token 归档目录、记忆文件、总结文件、定时任务文件 +8. **数据持久化层**:历史数据目录、FAQ 目录、Token 归档目录、记忆文件、总结文件、定时任务文件、微信绑定/游标/隔离/审计状态 + +### 微信 iLink 路由边界 + +微信接入由主进程独立管理,不依赖 OneBot 是否在线。`WeixinService` 先校验帐号与 peer,再把已绑定来源映射为逻辑 QQ 私聊交给 `MessageHandler`;未知来源在写历史或调用 AI 之前进入隔离存储。`DeliveryAddress` 将逻辑身份与物理通道分离:`qq:` 和 `wechat:` 可共享同一个用户历史、认知记忆与权限,但发送器、消息合并 scope 和 transport 元数据保持通道隔离。定时任务同样持久化规范 `address`,旧 `target_type + target_id` 在加载时转换为 `group:` 或 `qq:` 地址。 ### "车站-列车" 队列模型 @@ -909,6 +914,6 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 --- -**架构图版本**: v3.4.1 -**更新日期**: 2026-05-10 +**架构图版本**: v3.8.1 +**更新日期**: 2026-07-14 **基于代码版本**: 最新 main 分支 diff --git a/README.md b/README.md index 9a858fbf..0404d028 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Undefined

- QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11. + QQ and WeChat bot platform with cognitive memory architecture and multi-agent Skills.

Python uv @@ -15,7 +15,7 @@

项目简介

- Undefined 是一个基于 Python 异步架构的高性能 QQ 机器人平台,搭载认知记忆架构,采用自研 Skills 系统,内置多个智能 Agent,支持代码分析、网络搜索、娱乐互动等多模态能力,并提供 WebUI 在线管理,以及可连接同一管理服务的 跨平台 App。 + Undefined 是一个基于 Python 异步架构的高性能机器人平台,以 OneBot V11 接入 QQ,并可通过微信 ClawBot/iLink 接入微信私聊。项目搭载认知记忆架构,采用自研 Skills 系统,内置多个智能 Agent,支持代码分析、网络搜索、娱乐互动等多模态能力,并提供 WebUI 在线管理,以及可连接同一管理服务的 跨平台 App

@@ -67,6 +67,7 @@ Console 和 Chat 都需要连接到已经运行的 Undefined 服务。首次部 - **置顶备忘录**(`memory.*`):AI 自身的置顶提醒(自我约束、待办事项),每轮固定注入,支持增删改查 详见 [认知记忆文档](docs/cognitive-memory.md)。 - **Management-first WebUI**:继续保留 `uv run Undefined-webui` 一键入口;即使 `config.toml` 缺失或未配完,也能先进入管理态补配置、看日志、校验并启动 Bot。 +- **微信 ClawBot/iLink 私聊**:可将一个微信帐号映射为逻辑 QQ 身份,共享权限、私聊历史、认知记忆和模型偏好,同时用 `wechat:` 保持物理回复路由隔离;带二维码管理、未知来源隔离、高权限身份二次确认和审计。无需安装 OpenClaw 或微信插件。详见 [微信 iLink 接入](docs/wechat-ilink.md)。 - **远程管理 + 多端客户端**:浏览器版 WebUI、跨平台 Console(管理客户端)和原生优先 Undefined Chat(聊天客户端)共享同一套 Management / Runtime 服务,支持远程管理,并覆盖 `Windows / macOS / Linux / Android` 发布链路。 - **Undefined Console**:基于 Tauri v2 的管理客户端,完整管理功能 - **Undefined Chat**:基于 Tauri v2 + React 19 的原生优先聊天客户端,采用莫兰迪橙色系设计,移植 WebUI webchat 的核心聊天能力并做原生增强:中英双语运行时切换(i18n)、平台抽象层(按真实平台区分桌面/移动布局)、桌面快捷键、系统凭据存储、HTML 正文 sanitize 内联渲染 + 独立预览窗口隔离运行、Android(非 iOS)横屏/平板适配。iOS 暂不作为发布平台 @@ -121,6 +122,7 @@ Undefined 的功能极为丰富,为了让本页面不过于臃肿,我们将 - 🔄 **[多模型并发竞技](docs/multi-model.md)**:配置多个异构模型,让它们并行运算、同台 PK,从中择优响应。 - ⌨️ **[命令系统与斜杠指令](docs/slash-commands.md)**:查阅所有斜杠指令(`/*`)的详细用法,并学习如何轻松扩展你自己的指令系统。 - 🌐 **[Runtime API 与 OpenAPI](docs/openapi.md)**:主进程 Runtime API、鉴权、探针、记忆/侧写查询和运行态集成说明。 +- 💬 **[微信 ClawBot / iLink 接入](docs/wechat-ilink.md)**:逻辑 QQ 身份映射、`wechat:` 路由、扫码管理、隔离与媒体能力说明。 - 💬 **[Undefined Chat](docs/undefined-chat.md)**:原生优先 WebChat 客户端说明——莫兰迪橙色系设计、功能对等表、平台差异(桌面快捷键/独立窗口、Android 生命周期)、Runtime 真源、SSE/JSON fallback、安全存储、附件上传和 HTML 预览隔离。 - 🏗️ **[构建指南](docs/build.md)**:Python 包、WebUI、跨平台 App、Android 与 Release 工作流的构建说明。 - 🔧 **[运维脚本](scripts/README.md)**:嵌入模型更换后的向量库重嵌入等维护工具。 diff --git a/config.toml.example b/config.toml.example index 0dfdb392..ac1a9615 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1397,6 +1397,24 @@ autostart_bot = false # en: Check GitHub Releases for updates in the background whenever WebUI is opened. check_updates = true +# zh: 微信 ClawBot/iLink 私聊接入。凭据与游标仅写入 state_dir,不写入本配置。 +# zh: 关闭时不会创建网络连接;二维码登录只能从管理界面显式发起。 +# en: WeChat ClawBot/iLink private-chat integration. Credentials and cursors are +# en: stored under state_dir and never in this configuration file. +[weixin] +enabled = false +state_dir = "data/weixin" +long_poll_timeout_seconds = 35.0 +stale_token_pause_seconds = 3600.0 +retry_delay_seconds = 2.0 +failure_backoff_seconds = 30.0 +failures_before_backoff = 3 +media_max_size_mb = 100 +login_session_ttl_seconds = 300.0 +privileged_confirmation_ttl_seconds = 300.0 +pending_max_records = 100 +audit_max_records = 1000 + # zh: 主进程 Runtime API(供 WebUI/外部系统读取探针、记忆检索、AI Chat 使用)。 # en: Runtime API in the main process (for WebUI/external integrations: probes, memory queries, AI chat). [api] diff --git a/docs/access-control.md b/docs/access-control.md index 7c127c29..7f1b1b00 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -81,6 +81,8 @@ superadmin_bypass_allowlist = true 因此,配置可统一约束“收消息”和“发消息”。 +微信 iLink 私聊按其绑定的逻辑 QQ 号参与 `allowed_private_ids` / `blocked_private_ids` 判定。绑定不会绕过名单;若逻辑 QQ 是 superadmin,则继续遵循本页两个 superadmin 绕过开关。未知微信来源会在访问控制和消息处理之前隔离,详见 [微信 iLink 接入](wechat-ilink.md)。 + ## 与 Naga 会话策略的关系 -全局 `[access]` 控制机器人整体能否收发消息。Naga 另有独立的会话策略(`[naga].mode` + 群/私聊名单),用于在 Naga 总闸打开后,按群/私聊决定是否启用 NagaAgent 提示词/工具与外部网关(`/naga`、绑定、回调投递)。模式名与本节相同(`off` / `blacklist` / `allowlist`),但 **Naga 的 allowlist 空名单为 fail closed(拒绝全部)**,与本节「空名单不限制」不同;详见 [configuration.md §4.27](configuration.md)。 +全局 `[access]` 控制机器人整体能否收发消息。Naga 另有独立的会话策略(`[naga].mode` + 群/私聊名单),用于在 Naga 总闸打开后,按群/私聊决定是否启用 NagaAgent 提示词/工具与外部网关(`/naga`、绑定、回调投递)。模式名与本节相同(`off` / `blacklist` / `allowlist`),但 **Naga 的 allowlist 空名单为 fail closed(拒绝全部)**,与本节「空名单不限制」不同;详见 [configuration.md §4.28](configuration.md)。 diff --git a/docs/configuration.md b/docs/configuration.md index 4383645b..a9ad53b0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -999,7 +999,34 @@ Prompt caching 补充: --- -### 4.24 `[api]` Runtime API / OpenAPI +### 4.24 `[weixin]` 微信 ClawBot / iLink 私聊 + +| 字段 | 默认值 | 说明 | +|---|---:|---| +| `enabled` | `false` | 微信 iLink 总开关;关闭时不建立网络连接 | +| `state_dir` | `data/weixin` | 敏感凭据、游标、绑定、隔离和审计状态目录 | +| `long_poll_timeout_seconds` | `35.0` | 单次消息长轮询超时 | +| `stale_token_pause_seconds` | `3600.0` | token 失效提示后的暂停时间 | +| `retry_delay_seconds` | `2.0` | 普通失败重试间隔 | +| `failure_backoff_seconds` | `30.0` | 连续失败后的退避时间 | +| `failures_before_backoff` | `3` | 进入长退避的连续失败阈值 | +| `media_max_size_mb` | `100` | 单个媒体大小上限 | +| `login_session_ttl_seconds` | `300.0` | 二维码登录会话有效期 | +| `privileged_confirmation_ttl_seconds` | `300.0` | 管理员身份二次确认有效期 | +| `pending_max_records` | `100` | 未知来源隔离记录上限 | +| `audit_max_records` | `1000` | 帐号管理审计记录上限 | + +关键行为: +- 修改本节后需重启 Bot 主进程;二维码登录只能从已鉴权管理页或 Runtime API 显式发起。 +- 每个微信帐号绑定一个逻辑 QQ 身份,共享私聊权限、历史、认知记忆和模型偏好;物理回复地址为 `wechat:<逻辑QQ号>`。 +- 未匹配来源在进入命令、历史和 AI 前隔离,隔离记录不保存正文。 +- `state_dir` 中包含登录凭据,不应提交到版本库或通过静态文件服务暴露。 + +详见 [微信 iLink 接入](wechat-ilink.md)。 + +--- + +### 4.25 `[api]` Runtime API / OpenAPI | 字段 | 默认值 | 说明 | |---|---:|---| @@ -1019,23 +1046,23 @@ Prompt caching 补充: --- -### 4.25 `[cognitive]` 认知记忆 +### 4.26 `[cognitive]` 认知记忆 -### 4.24.1 根配置 +### 4.26.1 根配置 | 字段 | 默认值 | 说明 | |---|---:|---| | `enabled` | `true` | 开启认知记忆 | | `bot_name` | `Undefined` | 史官改写中使用的 bot 名称 | -### 4.24.2 `[cognitive.vector_store]` +### 4.26.2 `[cognitive.vector_store]` | 字段 | 默认值 | 说明 | |---|---:|---| | `path` | `data/cognitive/chromadb` | Chroma 存储目录 | | `scheduler_foreground_burst` | `8` | Chroma 前台连续处理上限;达到后若有维护/后台任务,会让出一次执行机会。需重启 | -### 4.24.3 `[cognitive.query]` +### 4.26.3 `[cognitive.query]` | 字段 | 默认值 | 说明 | |---|---:|---| @@ -1054,7 +1081,7 @@ Prompt caching 补充: | `profile_top_k` | `8` | 侧写检索 top-k | | `rerank_candidate_multiplier` | `3` | 候选倍数(`top_k * multiplier`) | -### 4.24.4 `[cognitive.historian]` +### 4.26.4 `[cognitive.historian]` | 字段 | 默认值 | 说明 | |---|---:|---| @@ -1065,14 +1092,14 @@ Prompt caching 补充: | `poll_interval_seconds` | `1.0` | 队列轮询间隔 | | `stale_job_timeout_seconds` | `300.0` | processing 超时回收阈值 | -### 4.24.5 `[cognitive.profile]` +### 4.26.5 `[cognitive.profile]` | 字段 | 默认值 | 说明 | |---|---:|---| | `path` | `data/cognitive/profiles` | 侧写存储目录 | | `revision_keep` | `5` | 保留历史版本数量 | -### 4.24.6 `[cognitive.queue]` +### 4.26.6 `[cognitive.queue]` | 字段 | 默认值 | 说明 | |---|---:|---| @@ -1084,7 +1111,7 @@ Prompt caching 补充: --- -### 4.26 `[memes]` 表情包库 +### 4.27 `[memes]` 表情包库 | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| @@ -1124,7 +1151,7 @@ Prompt caching 补充: - 关键词检索会按空白切分查询词项并构造 FTS phrase,因此中文标签、别名或描述词同样可以走 FTS 召回。 - `query_default_mode` 只影响 `memes.search_memes` 未显式传 `query_mode` 时的默认值。 -### 4.27 `[naga]` Naga 外部网关集成 +### 4.28 `[naga]` Naga 外部网关集成 > **⚠️ 此功能面向与 NagaAgent 对接的高级场景,普通用户不建议开启。** diff --git a/docs/management-api.md b/docs/management-api.md index 0b2b8bbc..78af7ccc 100644 --- a/docs/management-api.md +++ b/docs/management-api.md @@ -175,6 +175,18 @@ Management API 会把运行态相关能力统一代理到主进程 Runtime API - `GET /api/v1/management/runtime/schedules/{task_id}` - `PATCH /api/v1/management/runtime/schedules/{task_id}` - `DELETE /api/v1/management/runtime/schedules/{task_id}` +- `GET /api/v1/management/runtime/weixin` +- `POST /api/v1/management/runtime/weixin/login` +- `GET /api/v1/management/runtime/weixin/login/{session_id}` +- `GET /api/v1/management/runtime/weixin/login/{session_id}/qr.png` +- `POST /api/v1/management/runtime/weixin/login/{session_id}/refresh` +- `POST /api/v1/management/runtime/weixin/login/{session_id}/verify` +- `DELETE /api/v1/management/runtime/weixin/login/{session_id}` +- `PATCH /api/v1/management/runtime/weixin/accounts/{alias}` +- `DELETE /api/v1/management/runtime/weixin/accounts/{alias}` +- `GET /api/v1/management/runtime/weixin/pending` +- `DELETE /api/v1/management/runtime/weixin/pending/{record_id}` +- `GET /api/v1/management/runtime/weixin/audit` - `GET /api/v1/management/runtime/cognitive/events` - `GET /api/v1/management/runtime/cognitive/profiles` - `GET /api/v1/management/runtime/cognitive/profile/{entity_type}/{entity_id}` @@ -368,6 +380,8 @@ data: {"stage":"waiting_tools"} 定时任务代理用于 WebUI“定时任务”页。Management API 会先校验 WebUI 登录态,再在服务端注入 Runtime API 的 `X-Undefined-API-Key` 请求头;浏览器前端不会直接接触 `[api].auth_key`。 +微信代理用于 WebUI“微信接入”页,覆盖状态、二维码登录、验证码、帐号启停/改绑/解绑、未知来源隔离和审计。二维码端点按二进制响应代理并保留禁止缓存语义;其他端点按 JSON 代理。iLink 凭据和 Runtime API Key 都只存在于服务端,详见 [微信 iLink 接入](wechat-ilink.md)。 + 除此之外,Management API 还额外代理了表情包库管理接口: - `GET /api/v1/management/memes` diff --git a/docs/message-batching.md b/docs/message-batching.md index 8cf69444..c620f0ac 100644 --- a/docs/message-batching.md +++ b/docs/message-batching.md @@ -6,7 +6,7 @@ ## 设计要点 -- **作用域**:按 `(scope, sender_id)` 分桶。`scope` 群聊为 `group:`,私聊为 `private:`。 +- **作用域**:按 `(scope, sender_id)` 分桶。`scope` 群聊为 `group:`,QQ 私聊为 `private:`,微信私聊为 `private:wechat:<逻辑QQ号>`。QQ 与微信可以共享逻辑私聊历史,但不会合并到同一个物理回复批次。 - **窗口策略**: - `extend`(默认):每条新消息重置定时器,并以 `max_window_seconds` 作为硬顶。 - `fixed`:定时器从首条算起;窗口期结束统一发车。 diff --git a/docs/openapi.md b/docs/openapi.md index 1472684b..4d1878db 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -229,6 +229,7 @@ curl http://127.0.0.1:8788/openapi.json "task_name": "每日摘要", "mode": "self_instruction", "cron": "0 9 * * *", + "address": "group:123456", "target_type": "group", "target_id": 123456, "tool_name": "scheduler.call_self", @@ -257,6 +258,7 @@ curl http://127.0.0.1:8788/openapi.json | `task_id` | 创建时可选;不传时自动生成。新建 ID 只允许字母、数字、`_`、`.`、`:`、`-`,最长 96 字符;已有历史任务即使 ID 含中文,也可继续通过详情、更新和删除接口管理 | | `task_name` | 可选的可读名称 | | `cron_expression` | 标准 5 段 crontab 表达式;也兼容字段名 `cron` | +| `address` | 推荐的规范投递地址:`qq:`、`group:<群号>` 或 `wechat:<逻辑QQ号>`;`PATCH` 时传 `null` 可清空 | | `target_type` | `group` 或 `private`,默认 `group` | | `target_id` | 可选的发送目标 ID;`PATCH` 时传 `null` 可清空 | | `max_executions` | 可选的最大执行次数;`PATCH` 时传 `null` 可清空 | @@ -270,8 +272,7 @@ curl http://127.0.0.1:8788/openapi.json "cron_expression": "0 9 * * *", "mode": "self_instruction", "self_instruction": "请总结昨天的待办,并提醒我今天优先处理前三项。", - "target_type": "private", - "target_id": 12345678 + "address": "wechat:12345678" } ``` @@ -306,6 +307,22 @@ curl http://127.0.0.1:8788/openapi.json - `tool_args` 必须是 JSON 对象;`tools` 必须是非空数组,最多 20 项。 - 所有 `/api/v1/schedules*` 路由都遵循 Runtime API 的 `X-Undefined-API-Key` 鉴权。 +### 微信 ClawBot / iLink + +- `GET /api/v1/weixin`:服务、帐号连接状态和媒体能力。 +- `POST /api/v1/weixin/login`:创建二维码登录会话。Body 为 `{"alias":"primary","qq_id":12345678}`;管理员身份首次提交返回 `409`、警告与 `confirmation_token`,第二次提交同一参数及 token 后继续。 +- `GET /api/v1/weixin/login/{session_id}`:查询扫码、验证码、确认或过期状态。 +- `GET /api/v1/weixin/login/{session_id}/qr.png`:二维码 PNG,响应禁止缓存。 +- `POST /api/v1/weixin/login/{session_id}/refresh`:刷新二维码。 +- `POST /api/v1/weixin/login/{session_id}/verify`:提交 `{"code":"123456"}`。 +- `DELETE /api/v1/weixin/login/{session_id}`:取消未完成的登录会话。 +- `PATCH /api/v1/weixin/accounts/{alias}`:提交 `{"enabled":false}` 启停帐号,或提交 `{"qq_id":12345678}` 改绑逻辑身份;高权限改绑同样要求二次确认。 +- `DELETE /api/v1/weixin/accounts/{alias}`:停止帐号并删除本地绑定凭据。 +- `GET /api/v1/weixin/pending` / `DELETE /api/v1/weixin/pending/{record_id}`:查看或忽略未知来源隔离记录。 +- `GET /api/v1/weixin/audit?limit=100`:读取最近帐号管理审计。 + +公开响应不会返回 iLink token、account ID、peer ID 或二维码原始载荷。所有端点都遵循 Runtime API Key 鉴权;WebUI 使用 Management 代理。身份映射、安全边界和媒体限制见 [微信 iLink 接入](wechat-ilink.md)。 + ### 认知记忆检索 / 侧写 - `GET /api/v1/cognitive/events?q=...` diff --git a/docs/pipelines.md b/docs/pipelines.md index 5e8cba83..6086daef 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -104,10 +104,11 @@ handler.py 需要导出 `detect` 和 `process` 两个顶层异步函数。 | key | 类型 | 说明 | |-----|------|------| | `config` | object | 运行时配置对象(含 `xxx_auto_extract_enabled`、`is_xxx_auto_extract_allowed_group/private` 等方法) | -| `sender` | object | 消息发送器 | +| `sender` | object | 当前私聊可能已绑定规范投递地址的消息发送器 | | `onebot` | object | OneBot 客户端 | | `target_id` | int | 群号或私聊 QQ 号 | | `target_type` | str | `"group"` 或 `"private"` | +| `address` | DeliveryAddress / null | 当前物理投递地址;微信私聊为 `wechat:<逻辑QQ号>`,管线发送结果时应优先复用 `sender` | | `text` | str | 提取的纯文本内容 | | `message_content` | list[dict] | 原始消息段列表 | | `extract_xxx_ids` | callable | 提取器函数 | diff --git a/docs/usage.md b/docs/usage.md index e72e1f20..76e8e29a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -321,6 +321,8 @@ HTML 和 Markdown 工具都支持显式长图版式: 也可以在 WebUI 的“定时任务”页查看、创建、编辑和删除当前调度任务;WebUI 会通过已鉴权的 Management 代理访问 Runtime API,不会把 Runtime API 密钥暴露给浏览器前端。 +发送目标使用统一投递地址:QQ 私聊为 `qq:`,群聊为 `group:<群号>`,微信私聊为 `wechat:<逻辑QQ号>`。从当前会话创建任务时默认继承物理通道,因此微信中创建的提醒仍从微信返回;也可通过 `address` 显式指定。旧的 `target_type + target_id` 继续兼容。 + ### 执行模式 | 模式 | 描述 | 配置字段 | diff --git a/docs/webui-guide.md b/docs/webui-guide.md index 205dd730..45a65898 100644 --- a/docs/webui-guide.md +++ b/docs/webui-guide.md @@ -37,7 +37,7 @@ check_updates = true # 打开 WebUI 时后台检查正式 Release(默认 tr ## 功能概览 -WebUI 共有 9 个主要页签(Tab),下面逐一介绍。 +WebUI 的主要页签如下。 ### 概览(Overview) @@ -124,11 +124,23 @@ AI 的置顶备忘录(自我约束、待办事项等),支持完整 CRUD: 管理当前运行中的调度任务: - **任务列表**:按任务 ID、名称、crontab、目标和模式搜索;列表展示下次执行时间、发送目标和任务模式。 -- **创建 / 编辑**:支持单工具、多工具和 AI 自我督办三种模式,可调整 `cron_expression`、目标类型 / ID、最大执行次数和执行内容。 +- **创建 / 编辑**:支持单工具、多工具和 AI 自我督办三种模式,可调整 `cron_expression`、统一投递地址、最大执行次数和执行内容。地址支持 `qq:`、`group:<群号>` 和 `wechat:<逻辑QQ号>`。 - **删除任务**:从 WebUI 直接删除不再需要的调度任务。 WebUI 会先验证登录态,再通过后端代理访问 Runtime API 的定时任务接口;浏览器前端不会直接读取或暴露 `[api].auth_key`。 +### 微信接入(WeChat) + +管理微信 ClawBot/iLink 私聊通道: + +- **扫码绑定**:填写本地帐号别名与逻辑 QQ 号,在页面内刷新二维码、查询扫码状态和提交验证码。 +- **帐号生命周期**:查看在线/离线/异常状态,启停、改绑或解绑帐号。 +- **权限确认**:绑定管理员或超级管理员逻辑 QQ 时,页面要求第二次明确确认。 +- **隔离来源**:只显示未匹配来源的元数据与计数;消息正文不会进入 WebUI、历史或 AI。 +- **审计记录**:查看登录、绑定、启停、改绑、解绑和高权限确认操作。 + +该页面要求 Bot 主进程正在运行且 `[weixin].enabled = true`。二维码和帐号管理请求由已鉴权的 Management 后端代理到 Runtime API,浏览器不会读取 Runtime API Key 或 iLink 凭据。完整安全边界与实机验证步骤见 [微信 iLink 接入](wechat-ilink.md)。 + ### AI 对话(Chat) WebUI 内置的对话界面,直接与 Bot 的 AI 进行交互: diff --git a/docs/wechat-ilink.md b/docs/wechat-ilink.md new file mode 100644 index 00000000..be5e46c0 --- /dev/null +++ b/docs/wechat-ilink.md @@ -0,0 +1,137 @@ +# 微信 ClawBot / iLink 私聊接入 + +Undefined 可以通过微信 ClawBot 的 iLink 接口接收和发送微信私聊消息。该接入不依赖 OneBot 在线状态,也不需要安装 OpenClaw 或微信插件;Undefined 主进程直接管理 iLink 帐号的登录、长轮询和发送生命周期。 + +> 这是基于公开可获取包体与网络协议行为实现的非官方兼容层,不是腾讯提供的稳定 SDK。上游协议、风控或可用范围可能变化。首次启用前请自行确认帐号和服务条款风险,并先使用非关键帐号验证。 + +## 身份与路由 + +每个微信帐号绑定一个“逻辑 QQ 号”。逻辑身份决定权限、私聊历史、认知记忆和模型偏好;物理通道只决定回复从 QQ 还是微信发出。 + +例如,微信帐号绑定逻辑 QQ `12345678` 后: + +- AI 输入中的 `sender_id` 仍为 `12345678`。 +- 微信入站消息带有 `channel="wechat"`、`address="wechat:12345678"` 和“微信私聊”位置。 +- 历史与 QQ 私聊 `12345678` 共用逻辑会话,但每条记录保留 transport 元数据。 +- QQ 与微信的短窗口消息合并桶分开,避免两个物理通道互相抢占回复。 + +显式投递统一使用规范地址: + +| 地址 | 含义 | +|---|---| +| `qq:12345678` | QQ 私聊 | +| `wechat:12345678` | 绑定到该逻辑 QQ 的微信私聊 | +| `group:87654321` | QQ 群聊 | + +`messages.send_message`、`messages.send_private_message` 和定时任务均支持 `address`。旧的 `target_type + target_id`、`user_id`、`group_id` 参数继续兼容,但无法表达微信物理通道,新增配置应优先使用规范地址。 + +## 配置 + +先在 `config.toml` 中启用接入,再重启 Bot 主进程: + +```toml +[weixin] +enabled = true +state_dir = "data/weixin" +long_poll_timeout_seconds = 35.0 +stale_token_pause_seconds = 3600.0 +retry_delay_seconds = 2.0 +failure_backoff_seconds = 30.0 +failures_before_backoff = 3 +media_max_size_mb = 100 +login_session_ttl_seconds = 300.0 +privileged_confirmation_ttl_seconds = 300.0 +pending_max_records = 100 +audit_max_records = 1000 +``` + +| 字段 | 作用 | +|---|---| +| `enabled` | 总开关;为 `false` 时不建立 iLink 网络连接 | +| `state_dir` | 帐号凭据、游标、隔离记录和审计记录目录 | +| `long_poll_timeout_seconds` | 单次消息长轮询超时 | +| `stale_token_pause_seconds` | 上游提示 token 过期时的暂停时间 | +| `retry_delay_seconds` | 普通失败后的重试间隔 | +| `failure_backoff_seconds` | 连续失败达到阈值后的退避时间 | +| `failures_before_backoff` | 进入长退避前允许的连续失败次数 | +| `media_max_size_mb` | 单个入站或出站媒体的大小上限 | +| `login_session_ttl_seconds` | 二维码登录会话有效期 | +| `privileged_confirmation_ttl_seconds` | 管理员身份二次确认 token 有效期 | +| `pending_max_records` | 未知来源隔离记录上限 | +| `audit_max_records` | 帐号管理审计记录上限 | + +`state_dir/bindings.json` 和 `state_dir/runtime.json` 包含敏感登录状态,在 POSIX 系统上会设置为 `0600`。不要提交、共享或放入 Web 静态目录;备份时按凭据文件处理。 + +## 管理页绑定 + +1. 启动 `Undefined-webui`,并从 WebUI 启动 Bot。 +2. 打开“微信接入”页,确认 Runtime 显示运行中。 +3. 点击“绑定帐号”,填写本地别名和要继承的逻辑 QQ 号。 +4. 使用微信扫描页面生成的二维码;上游要求验证码时在同一对话框提交。 +5. 绑定成功后确认帐号显示在线,再发送一条测试私聊。 + +别名只用于本机管理和日志,不会发送给微信。一个逻辑 QQ 在全局最多绑定一个微信帐号,同一 ClawBot 帐号也不能重复绑定。 + +若目标 QQ 是管理员或超级管理员,第一次提交只返回权限继承警告和短时确认 token;必须再次明确确认才会创建二维码登录。确认、登录、启停、改绑和解绑都会写入审计记录。 + +## 隔离与访问控制 + +iLink 帐号只接受登录结果声明的那个私聊来源。若收到帐号 ID 或来源 ID 不匹配的消息,Undefined 会在 AI、命令、管线和历史处理之前隔离: + +- 不保存消息正文或附件。 +- 不调用 AI 或命令。 +- 不发送自动回复。 +- 只保存帐号别名、来源 ID、原因、首次/最后出现时间和计数。 + +管理页可以查看并忽略隔离记录。已绑定消息仍遵循 `[access]` 的私聊名单;绑定微信不会绕过访问控制。绑定管理员逻辑 QQ 会完整继承其权限,因此必须保护 WebUI 和 Runtime API 鉴权密钥。 + +## 媒体与限制 + +当前支持: + +- 入站:文本、图片、文件、视频、语音转写;原始 SILK 语音作为附件保留。 +- 出站:文本、图片、文件、视频和输入中状态;超过 4000 字符的文本会自动分片。 +- 明确不支持:微信出站语音、`reply_to` 引用回复、群聊。 + +微信没有 QQ 合并转发的等价合同。对当前微信私聊发送合并转发时,Undefined 会按节点顺序降级为普通文本和媒体。包含本地 CQ 图片/文件的视频消息会走统一附件发送层;语音 CQ 码会返回不支持错误,不会悄悄改走 QQ。 + +## Runtime API + +所有端点使用 Runtime API 的 `X-Undefined-API-Key` 鉴权。WebUI 通过 Management 后端代理调用,不把 API Key 暴露给浏览器。 + +| 方法 | 路径 | 作用 | +|---|---|---| +| `GET` | `/api/v1/weixin` | 服务和帐号状态 | +| `POST` | `/api/v1/weixin/login` | 创建二维码登录会话 | +| `GET` | `/api/v1/weixin/login/{session_id}` | 查询扫码状态 | +| `GET` | `/api/v1/weixin/login/{session_id}/qr.png` | 获取无缓存二维码 PNG | +| `POST` | `/api/v1/weixin/login/{session_id}/refresh` | 刷新二维码 | +| `POST` | `/api/v1/weixin/login/{session_id}/verify` | 提交验证码 | +| `DELETE` | `/api/v1/weixin/login/{session_id}` | 取消登录会话 | +| `PATCH` | `/api/v1/weixin/accounts/{alias}` | 启停帐号或改绑逻辑 QQ | +| `DELETE` | `/api/v1/weixin/accounts/{alias}` | 解绑并删除本地凭据 | +| `GET` | `/api/v1/weixin/pending` | 查看隔离来源 | +| `DELETE` | `/api/v1/weixin/pending/{record_id}` | 忽略隔离记录 | +| `GET` | `/api/v1/weixin/audit` | 查看帐号管理审计 | + +二维码响应使用 `Cache-Control: no-store`。API 状态和公开帐号结构不会返回 bot token、account ID、peer ID 或二维码原始登录载荷。 + +## 可复用 Python SDK + +底层协议客户端已独立为 MIT 项目 [weixin-ilink-client](https://github.com/69gg/weixin-ilink-client),包名同为 `weixin-ilink-client`。Undefined 仅负责身份绑定、访问控制、历史、附件、调度和管理 API;二维码登录、协议传输、长轮询、媒体传输和状态存储接口由 SDK 提供。 + +SDK 作者信息为 Null ``,支持 Python 3.11 及以上。Undefined 固定使用兼容的 `0.1.x` 版本范围,避免未经验证的协议破坏性升级自动进入运行环境。 + +## 实机验证清单 + +仓库测试不会发起真实二维码登录或连接微信。首次实机验证建议逐项确认: + +1. 二维码刷新、取消、过期和验证码状态均能收敛。 +2. 文本入站只回复到 `wechat:<逻辑QQ号>`,不会误发到 QQ。 +3. 同一逻辑 QQ 的历史和认知记忆可见,但微信与 QQ 的并发回复路由不串线。 +4. 未知来源只增加隔离计数,历史中没有正文。 +5. 图片、文件、视频和语音转写分别测试大小上限和失败提示。 +6. 停用、重启、改绑和解绑后的客户端任务与凭据状态符合预期。 +7. 管理员身份必须出现二次确认,审计记录包含对应操作。 + +若上游协议返回未知状态或字段,先停用该帐号并保留脱敏日志;不要反复扫码或高频重试,以免放大帐号风控风险。 diff --git a/pyproject.toml b/pyproject.toml index 1884c3ab..750ee130 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ dependencies = [ "pypinyin>=0.53.0", "chromadb>=1.5.5", "numba>=0.61.0", + "weixin-ilink-client>=0.1.0,<0.2.0", + "qrcode>=8.2,<9.0", ] [project.urls] diff --git a/src/Undefined/api/_context.py b/src/Undefined/api/_context.py index 8ca6fd07..708ab3b8 100644 --- a/src/Undefined/api/_context.py +++ b/src/Undefined/api/_context.py @@ -22,3 +22,4 @@ class RuntimeAPIContext: naga_store: Any = None message_batcher: Any = None pipeline_registry: Any = None + weixin_service: Any = None diff --git a/src/Undefined/api/_openapi.py b/src/Undefined/api/_openapi.py index 53f70a85..b03da83c 100644 --- a/src/Undefined/api/_openapi.py +++ b/src/Undefined/api/_openapi.py @@ -221,6 +221,45 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st ), } }, + "/api/v1/weixin": { + "get": {"summary": "Get WeChat iLink runtime and account status"} + }, + "/api/v1/weixin/login": { + "post": { + "summary": "Start a WeChat QR login", + "description": ( + "Binds one ClawBot account to a logical QQ identity. " + "Privileged QQ identities require a second request with the " + "returned confirmation_token." + ), + } + }, + "/api/v1/weixin/login/{session_id}": { + "get": {"summary": "Poll a WeChat QR login"}, + "delete": {"summary": "Cancel a WeChat QR login"}, + }, + "/api/v1/weixin/login/{session_id}/qr.png": { + "get": {"summary": "Render a temporary WeChat login QR code"} + }, + "/api/v1/weixin/login/{session_id}/refresh": { + "post": {"summary": "Refresh a WeChat login QR code"} + }, + "/api/v1/weixin/login/{session_id}/verify": { + "post": {"summary": "Submit a WeChat login verification code"} + }, + "/api/v1/weixin/accounts/{alias}": { + "patch": {"summary": "Enable, disable, or rebind a WeChat account"}, + "delete": {"summary": "Remove a local WeChat account binding"}, + }, + "/api/v1/weixin/pending": { + "get": {"summary": "List quarantined unknown WeChat peers"} + }, + "/api/v1/weixin/pending/{record_id}": { + "delete": {"summary": "Dismiss a quarantined WeChat peer"} + }, + "/api/v1/weixin/audit": { + "get": {"summary": "List local WeChat binding audit entries"} + }, } if naga_routes_enabled: diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 2a563669..60ea96b1 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -35,6 +35,7 @@ schedules, system, tools, + weixin, ) logger = logging.getLogger(__name__) @@ -213,6 +214,42 @@ async def _auth_middleware( ), web.get("/api/v1/tools", self._tools_list_handler), web.post("/api/v1/tools/invoke", self._tools_invoke_handler), + web.get("/api/v1/weixin", self._weixin_status_handler), + web.post("/api/v1/weixin/login", self._weixin_login_start_handler), + web.get( + "/api/v1/weixin/login/{session_id}", + self._weixin_login_poll_handler, + ), + web.post( + "/api/v1/weixin/login/{session_id}/refresh", + self._weixin_login_refresh_handler, + ), + web.post( + "/api/v1/weixin/login/{session_id}/verify", + self._weixin_login_verify_handler, + ), + web.delete( + "/api/v1/weixin/login/{session_id}", + self._weixin_login_cancel_handler, + ), + web.get( + "/api/v1/weixin/login/{session_id}/qr.png", + self._weixin_login_qr_handler, + ), + web.patch( + "/api/v1/weixin/accounts/{alias}", + self._weixin_account_update_handler, + ), + web.delete( + "/api/v1/weixin/accounts/{alias}", + self._weixin_account_delete_handler, + ), + web.get("/api/v1/weixin/pending", self._weixin_pending_list_handler), + web.delete( + "/api/v1/weixin/pending/{record_id}", + self._weixin_pending_delete_handler, + ), + web.get("/api/v1/weixin/audit", self._weixin_audit_list_handler), ] ) cfg = self._context.config_getter() @@ -443,6 +480,45 @@ async def _tools_invoke_handler(self, request: web.Request) -> Response: self._ctx, self._background_tasks, request ) + # WeChat iLink + async def _weixin_status_handler(self, request: web.Request) -> Response: + return await weixin.status_handler(self._ctx, request) + + async def _weixin_login_start_handler(self, request: web.Request) -> Response: + return await weixin.login_start_handler(self._ctx, request) + + async def _weixin_login_poll_handler(self, request: web.Request) -> Response: + return await weixin.login_poll_handler(self._ctx, request) + + async def _weixin_login_refresh_handler(self, request: web.Request) -> Response: + return await weixin.login_refresh_handler(self._ctx, request) + + async def _weixin_login_verify_handler(self, request: web.Request) -> Response: + return await weixin.login_verify_handler(self._ctx, request) + + async def _weixin_login_cancel_handler(self, request: web.Request) -> Response: + return await weixin.login_cancel_handler(self._ctx, request) + + async def _weixin_login_qr_handler( + self, request: web.Request + ) -> web.StreamResponse: + return await weixin.login_qr_handler(self._ctx, request) + + async def _weixin_account_update_handler(self, request: web.Request) -> Response: + return await weixin.account_update_handler(self._ctx, request) + + async def _weixin_account_delete_handler(self, request: web.Request) -> Response: + return await weixin.account_delete_handler(self._ctx, request) + + async def _weixin_pending_list_handler(self, request: web.Request) -> Response: + return await weixin.pending_list_handler(self._ctx, request) + + async def _weixin_pending_delete_handler(self, request: web.Request) -> Response: + return await weixin.pending_delete_handler(self._ctx, request) + + async def _weixin_audit_list_handler(self, request: web.Request) -> Response: + return await weixin.audit_list_handler(self._ctx, request) + async def _execute_tool_invoke( self, *, diff --git a/src/Undefined/api/routes/schedules.py b/src/Undefined/api/routes/schedules.py index e4cd4a1e..ddfb8c8e 100644 --- a/src/Undefined/api/routes/schedules.py +++ b/src/Undefined/api/routes/schedules.py @@ -13,6 +13,7 @@ from Undefined.api._context import RuntimeAPIContext from Undefined.api._helpers import _json_error +from Undefined.utils.message_targets import parse_delivery_address from Undefined.utils.scheduler import SELF_CALL_TOOL_NAME _TASK_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") @@ -104,6 +105,15 @@ def _parse_target_type(value: Any) -> str: return target_type +def _parse_address(value: Any) -> str | None: + if value is None or str(value).strip() == "": + return None + address, error = parse_delivery_address(value) + if error or address is None: + raise SchedulePayloadError(error or "address is invalid") + return address.canonical + + def _parse_execution_mode(value: Any) -> str: execution_mode = _clean_text( value or "serial", field="execution_mode", max_length=16 @@ -210,10 +220,14 @@ def _normalize_schedule_payload( normalized["task_name"] = task_name provided.add("task_name") + if "address" in body: + normalized["address"] = _parse_address(body.get("address")) + provided.add("address") + if "target_type" in body: normalized["target_type"] = _parse_target_type(body.get("target_type")) provided.add("target_type") - elif not partial: + elif not partial and "address" not in body: normalized["target_type"] = "group" provided.add("target_type") @@ -224,6 +238,19 @@ def _normalize_schedule_payload( ) provided.add("target_id") + if ( + normalized.get("address") is not None + and normalized.get("target_id") is not None + ): + legacy_channel = ( + "group" if normalized.get("target_type", "group") == "group" else "qq" + ) + legacy_address = f"{legacy_channel}:{normalized['target_id']}" + if legacy_address != normalized["address"]: + raise SchedulePayloadError( + "address conflicts with target_type and target_id" + ) + if "max_executions" in body: normalized["max_executions"] = _parse_optional_positive_int( body.get("max_executions"), @@ -335,6 +362,11 @@ def serialize_schedule_task( task.setdefault("task_id", task_id) task["mode"] = _schedule_task_mode(task) task["next_run_time"] = _next_run_time_iso(ctx, task_id) + address, _error = parse_delivery_address(task.get("address")) + if address is None and task.get("target_id") is not None: + channel = "group" if task.get("target_type") == "group" else "qq" + address, _error = parse_delivery_address(f"{channel}:{task['target_id']}") + task["address"] = address.canonical if address is not None else None tool_args = task.get("tool_args") tools = task.get("tools") if ( @@ -448,6 +480,7 @@ async def schedules_create_handler( cron_expression=str(normalized["cron_expression"]), target_id=normalized.get("target_id"), target_type=str(normalized.get("target_type") or "group"), + target_address=normalized.get("address"), task_name=normalized.get("task_name"), max_executions=normalized.get("max_executions"), tools=normalized.get("tools"), @@ -499,6 +532,9 @@ async def schedule_update_handler( kwargs["target_id_provided"] = True if "target_type" in provided: kwargs["target_type"] = normalized.get("target_type") + if "address" in provided: + kwargs["target_address"] = normalized.get("address") + kwargs["target_address_provided"] = True if "max_executions" in provided: kwargs["max_executions"] = normalized.get("max_executions") kwargs["max_executions_provided"] = True diff --git a/src/Undefined/api/routes/weixin.py b/src/Undefined/api/routes/weixin.py new file mode 100644 index 00000000..3b77dcfd --- /dev/null +++ b/src/Undefined/api/routes/weixin.py @@ -0,0 +1,285 @@ +"""微信 iLink 管理路由。""" + +from __future__ import annotations + +from io import BytesIO +from typing import Any + +from aiohttp import web +from aiohttp.web_response import Response +import qrcode + +from Undefined.api._context import RuntimeAPIContext +from Undefined.api._helpers import _json_error +from Undefined.weixin.service import ( + WeixinConfirmationRequired, + WeixinConflictError, + WeixinNotFoundError, + WeixinServiceError, + WeixinUpstreamError, +) + + +def _service(ctx: RuntimeAPIContext) -> Any | None: + return getattr(ctx, "weixin_service", None) + + +def _actor(ctx: RuntimeAPIContext) -> str: + config = ctx.config_getter() + return f"management:{int(getattr(config, 'superadmin_qq', 0) or 0)}" + + +async def _json_body( + request: web.Request, +) -> tuple[dict[str, Any] | None, Response | None]: + try: + value = await request.json() + except Exception: + return None, _json_error("Invalid JSON", status=400) + if not isinstance(value, dict): + return None, _json_error("JSON body must be an object", status=400) + return value, None + + +def _parse_qq_id(value: object) -> int | None: + if isinstance(value, bool): + return None + if not isinstance(value, (int, float, str)): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _service_error(exc: Exception) -> Response: + if isinstance(exc, WeixinConfirmationRequired): + return web.json_response( + { + "error": exc.warning, + "requires_confirmation": True, + "confirmation_token": exc.token, + "expires_at": exc.expires_at, + }, + status=409, + ) + if isinstance(exc, WeixinNotFoundError): + return _json_error(str(exc), status=404) + if isinstance(exc, WeixinConflictError): + return _json_error(str(exc), status=409) + if isinstance(exc, WeixinUpstreamError): + return _json_error(str(exc), status=502) + return _json_error(str(exc), status=400) + + +async def status_handler(ctx: RuntimeAPIContext, request: web.Request) -> Response: + del request + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + return web.json_response(await service.status()) + + +async def login_start_handler(ctx: RuntimeAPIContext, request: web.Request) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + body, error = await _json_body(request) + if error is not None or body is None: + return error or _json_error("Invalid JSON") + alias = str(body.get("alias", "") or "").strip() + qq_id = _parse_qq_id(body.get("qq_id")) + if not alias: + return _json_error("alias is required") + if qq_id is None: + return _json_error("qq_id must be a positive integer") + try: + result = await service.start_login( + alias=alias, + qq_id=qq_id, + confirmation_token=str(body.get("confirmation_token", "") or "") or None, + actor=_actor(ctx), + ) + except WeixinServiceError as exc: + return _service_error(exc) + payload = result.to_dict() + payload.pop("qrcode_payload", None) + payload["qr_image_url"] = f"/api/v1/weixin/login/{result.session_id}/qr.png" + return web.json_response(payload, status=201) + + +async def login_poll_handler(ctx: RuntimeAPIContext, request: web.Request) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + session_id = str(request.match_info.get("session_id", "") or "").strip() + try: + result = await service.poll_login(session_id, actor=_actor(ctx)) + except WeixinServiceError as exc: + return _service_error(exc) + return web.json_response(result.to_dict()) + + +async def login_refresh_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + session_id = str(request.match_info.get("session_id", "") or "").strip() + try: + result = await service.refresh_login(session_id) + except WeixinServiceError as exc: + return _service_error(exc) + payload = result.to_dict() + payload.pop("qrcode_payload", None) + payload["qr_image_url"] = f"/api/v1/weixin/login/{result.session_id}/qr.png" + return web.json_response(payload) + + +async def login_verify_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + body, error = await _json_body(request) + if error is not None or body is None: + return error or _json_error("Invalid JSON") + code = str(body.get("code", "") or "").strip() + if not code: + return _json_error("code is required") + session_id = str(request.match_info.get("session_id", "") or "").strip() + try: + await service.submit_verify_code(session_id, code) + except WeixinServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _json_error(str(exc), status=400) + return web.json_response({"session_id": session_id, "submitted": True}) + + +async def login_cancel_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + session_id = str(request.match_info.get("session_id", "") or "").strip() + deleted = await service.cancel_login(session_id) + if not deleted: + return _json_error("Login session not found", status=404) + return web.json_response({"session_id": session_id, "cancelled": True}) + + +async def login_qr_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> web.StreamResponse: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + session_id = str(request.match_info.get("session_id", "") or "").strip() + try: + payload = service.get_login_qrcode_payload(session_id) + except WeixinServiceError as exc: + return _service_error(exc) + code = qrcode.QRCode(border=2, box_size=8) + code.add_data(payload) + code.make(fit=True) + image = code.make_image(fill_color="black", back_color="white") + buffer = BytesIO() + image.save(buffer, format="PNG") + return web.Response( + body=buffer.getvalue(), + content_type="image/png", + headers={"Cache-Control": "no-store, max-age=0"}, + ) + + +async def account_update_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + body, error = await _json_body(request) + if error is not None or body is None: + return error or _json_error("Invalid JSON") + alias = str(request.match_info.get("alias", "") or "").strip() + try: + if "qq_id" in body: + qq_id = _parse_qq_id(body.get("qq_id")) + if qq_id is None: + return _json_error("qq_id must be a positive integer") + account = await service.rebind_account( + alias, + qq_id, + confirmation_token=( + str(body.get("confirmation_token", "") or "") or None + ), + actor=_actor(ctx), + ) + elif "enabled" in body and isinstance(body.get("enabled"), bool): + account = await service.set_account_enabled( + alias, + bool(body["enabled"]), + actor=_actor(ctx), + ) + else: + return _json_error("body must contain qq_id or boolean enabled") + except WeixinServiceError as exc: + return _service_error(exc) + return web.json_response({"account": account}) + + +async def account_delete_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + alias = str(request.match_info.get("alias", "") or "").strip() + try: + deleted = await service.remove_account(alias, actor=_actor(ctx)) + except WeixinServiceError as exc: + return _service_error(exc) + if not deleted: + return _json_error("Account not found", status=404) + return web.json_response({"alias": alias, "deleted": True}) + + +async def pending_list_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + del request + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + items = [item.to_dict() for item in await service.store.list_pending_peers()] + return web.json_response({"total": len(items), "items": items}) + + +async def pending_delete_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + record_id = str(request.match_info.get("record_id", "") or "").strip() + deleted = await service.store.dismiss_pending_peer(record_id) + if not deleted: + return _json_error("Pending peer not found", status=404) + return web.json_response({"id": record_id, "deleted": True}) + + +async def audit_list_handler(ctx: RuntimeAPIContext, request: web.Request) -> Response: + service = _service(ctx) + if service is None: + return _json_error("WeChat service not ready", status=503) + try: + limit = int(request.query.get("limit", "100")) + except ValueError: + return _json_error("limit must be an integer") + items = [item.to_dict() for item in await service.store.list_audit(limit=limit)] + return web.json_response({"total": len(items), "items": items}) diff --git a/src/Undefined/attachments/render.py b/src/Undefined/attachments/render.py index 6f3433c6..4139e027 100644 --- a/src/Undefined/attachments/render.py +++ b/src/Undefined/attachments/render.py @@ -221,6 +221,7 @@ async def dispatch_pending_file_sends( target_type: str, target_id: int, registry: AttachmentRegistry | None = None, + address: Any | None = None, ) -> None: """Send pending file attachments collected by *render_message_with_attachments*. @@ -258,7 +259,15 @@ async def dispatch_pending_file_sends( ) continue try: - if target_type == "group": + send_address_file = getattr(sender, "send_address_file", None) + if address is not None and callable(send_address_file): + await send_address_file( + address, + send_record.local_path, + name=send_record.display_name or None, + auto_history=False, + ) + elif target_type == "group": await sender.send_group_file( target_id, send_record.local_path, diff --git a/src/Undefined/config/__init__.py b/src/Undefined/config/__init__.py index 152ef846..14d1ea07 100644 --- a/src/Undefined/config/__init__.py +++ b/src/Undefined/config/__init__.py @@ -19,6 +19,7 @@ RerankModelConfig, SecurityModelConfig, VisionModelConfig, + WeixinConfig, ) from .webui_settings import WebUISettings, load_webui_settings @@ -39,6 +40,7 @@ "MessageBatcherConfig", "PromptSystemInfoConfig", "RenderCacheConfig", + "WeixinConfig", "get_config", "get_config_manager", "set_config", diff --git a/src/Undefined/config/config_class.py b/src/Undefined/config/config_class.py index 40d59516..bc2098e9 100644 --- a/src/Undefined/config/config_class.py +++ b/src/Undefined/config/config_class.py @@ -26,6 +26,7 @@ RerankModelConfig, SecurityModelConfig, VisionModelConfig, + WeixinConfig, ) from .toml_io import _load_env, load_toml_data @@ -145,6 +146,7 @@ class Config: webui_autostart_bot: bool webui_check_updates: bool api: APIConfig + weixin: WeixinConfig # Code Delivery Agent code_delivery_enabled: bool code_delivery_task_root: str diff --git a/src/Undefined/config/domain_parsers.py b/src/Undefined/config/domain_parsers.py index f21fbbeb..840fdb2c 100644 --- a/src/Undefined/config/domain_parsers.py +++ b/src/Undefined/config/domain_parsers.py @@ -23,6 +23,7 @@ NagaConfig, PromptSystemInfoConfig, RenderCacheConfig, + WeixinConfig, ) DEFAULT_API_HOST = "127.0.0.1" @@ -342,6 +343,42 @@ def _parse_api_config(data: dict[str, Any]) -> APIConfig: ) +def _parse_weixin_config(data: dict[str, Any]) -> WeixinConfig: + section_raw = data.get("weixin", {}) + section = section_raw if isinstance(section_raw, dict) else {} + return WeixinConfig( + enabled=_coerce_bool(section.get("enabled"), False), + state_dir=_coerce_str(section.get("state_dir"), "data/weixin"), + long_poll_timeout_seconds=max( + 1.0, _coerce_float(section.get("long_poll_timeout_seconds"), 35.0) + ), + stale_token_pause_seconds=max( + 1.0, _coerce_float(section.get("stale_token_pause_seconds"), 3600.0) + ), + retry_delay_seconds=max( + 0.1, _coerce_float(section.get("retry_delay_seconds"), 2.0) + ), + failure_backoff_seconds=max( + 0.1, _coerce_float(section.get("failure_backoff_seconds"), 30.0) + ), + failures_before_backoff=max( + 1, _coerce_int(section.get("failures_before_backoff"), 3) + ), + media_max_size_mb=max(1, _coerce_int(section.get("media_max_size_mb"), 100)), + login_session_ttl_seconds=max( + 30.0, _coerce_float(section.get("login_session_ttl_seconds"), 300.0) + ), + privileged_confirmation_ttl_seconds=max( + 30.0, + _coerce_float(section.get("privileged_confirmation_ttl_seconds"), 300.0), + ), + pending_max_records=max( + 1, _coerce_int(section.get("pending_max_records"), 100) + ), + audit_max_records=max(1, _coerce_int(section.get("audit_max_records"), 1000)), + ) + + def _parse_naga_config(data: dict[str, Any]) -> NagaConfig: section_raw = data.get("naga", {}) section = section_raw if isinstance(section_raw, dict) else {} diff --git a/src/Undefined/config/load_sections/domains.py b/src/Undefined/config/load_sections/domains.py index dd136660..a7dd673f 100644 --- a/src/Undefined/config/load_sections/domains.py +++ b/src/Undefined/config/load_sections/domains.py @@ -16,6 +16,7 @@ _parse_naga_config, _parse_prompt_system_info_config, _parse_render_cache_config, + _parse_weixin_config, ) from ..parsers import ( _parse_image_edit_model_config, @@ -40,6 +41,7 @@ def load_domains( prompt_system_info = _parse_prompt_system_info_config(data) render_cache = _parse_render_cache_config(data) naga = _parse_naga_config(data) + weixin = _parse_weixin_config(data) models_image_gen = _parse_image_gen_model_config(data) models_image_edit = _parse_image_edit_model_config(data) image_gen = _parse_image_gen_config(data) @@ -57,6 +59,7 @@ def load_domains( "prompt_system_info": prompt_system_info, "render_cache": render_cache, "naga": naga, + "weixin": weixin, "image_gen": image_gen, "models_image_gen": models_image_gen, "models_image_edit": models_image_edit, diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index 67ef47c6..20027002 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -592,3 +592,21 @@ def loopback_url(self) -> str: def display_url(self) -> str: """用于日志和展示的格式化 URL(保留原始 host,仅处理 IPv6 方括号)。""" return f"http://{format_netloc(self.host or '0.0.0.0', self.port)}" + + +@dataclass +class WeixinConfig: + """微信 iLink 私聊接入配置。""" + + enabled: bool = False + state_dir: str = "data/weixin" + long_poll_timeout_seconds: float = 35.0 + stale_token_pause_seconds: float = 3600.0 + retry_delay_seconds: float = 2.0 + failure_backoff_seconds: float = 30.0 + failures_before_backoff: int = 3 + media_max_size_mb: int = 100 + login_session_ttl_seconds: float = 300.0 + privileged_confirmation_ttl_seconds: float = 300.0 + pending_max_records: int = 100 + audit_max_records: int = 1000 diff --git a/src/Undefined/handlers/auto_extract.py b/src/Undefined/handlers/auto_extract.py index 690a3a6a..1e751b64 100644 --- a/src/Undefined/handlers/auto_extract.py +++ b/src/Undefined/handlers/auto_extract.py @@ -118,16 +118,18 @@ async def _handle_bilibili_extract( target_id: int, bvids: list[str], target_type: str, + sender: Any | None = None, ) -> None: """处理 bilibili 视频自动提取和发送。""" from Undefined.bilibili.sender import send_bilibili_video + resolved_sender = sender or self.sender for bvid in bvids[:3]: try: # 单条消息最多自动提取 3 个 BV await send_bilibili_video( video_id=bvid, - sender=self.sender, + sender=resolved_sender, onebot=self.onebot, target_type=target_type, # type: ignore[arg-type] target_id=target_id, @@ -151,9 +153,9 @@ async def _handle_bilibili_extract( try: error_msg = f"视频提取失败: {exc}" if target_type == "group": - await self.sender.send_group_message(target_id, error_msg) + await resolved_sender.send_group_message(target_id, error_msg) else: - await self.sender.send_private_message(target_id, error_msg) + await resolved_sender.send_private_message(target_id, error_msg) except Exception: pass @@ -162,6 +164,7 @@ async def _handle_douyin_extract( target_id: int, video_ids: list[str], target_type: str, + sender: Any | None = None, ) -> None: """处理 Douyin 视频自动提取和发送。""" from Undefined.douyin.sender import send_douyin_video @@ -176,11 +179,12 @@ async def _handle_douyin_extract( "360p", ) + resolved_sender = sender or self.sender for video_id in video_ids[:max_items]: try: result = await send_douyin_video( video_id=video_id, - sender=self.sender, + sender=resolved_sender, target_type=target_type, # type: ignore[arg-type] target_id=target_id, max_duration=self.config.douyin_max_duration, @@ -205,9 +209,9 @@ async def _handle_douyin_extract( try: error_msg = f"抖音视频提取失败: {exc}" if target_type == "group": - await self.sender.send_group_message(target_id, error_msg) + await resolved_sender.send_group_message(target_id, error_msg) else: - await self.sender.send_private_message(target_id, error_msg) + await resolved_sender.send_private_message(target_id, error_msg) except Exception: pass @@ -216,17 +220,19 @@ async def _handle_arxiv_extract( target_id: int, paper_ids: list[str], target_type: str, + sender: Any | None = None, ) -> None: """处理 arXiv 论文自动提取和发送。""" from Undefined.arxiv.sender import send_arxiv_paper max_items = max(1, int(self.config.arxiv_auto_extract_max_items)) + resolved_sender = sender or self.sender for paper_id in paper_ids[:max_items]: try: result = await send_arxiv_paper( paper_id=paper_id, - sender=self.sender, + sender=resolved_sender, target_type=target_type, # type: ignore[arg-type] target_id=target_id, max_file_size=self.config.arxiv_max_file_size, @@ -258,6 +264,7 @@ async def _handle_github_extract( target_id: int, repo_ids: list[str], target_type: str, + sender: Any | None = None, ) -> None: """处理 GitHub 仓库自动提取和发送。""" from Undefined.github.client import ( @@ -280,11 +287,12 @@ async def _handle_github_extract( getattr(self.config, "github_request_retries", DEFAULT_REQUEST_RETRIES) ) + resolved_sender = sender or self.sender for repo_id in repo_ids[:max_items]: try: result = await send_github_repo_card( repo_id=repo_id, - sender=self.sender, + sender=resolved_sender, target_type=target_type, # type: ignore[arg-type] target_id=target_id, request_timeout=request_timeout, diff --git a/src/Undefined/handlers/message_flow.py b/src/Undefined/handlers/message_flow.py index 3993dd14..17622005 100644 --- a/src/Undefined/handlers/message_flow.py +++ b/src/Undefined/handlers/message_flow.py @@ -49,6 +49,8 @@ from Undefined.utils.resources import resolve_resource_path from Undefined.utils.scheduler import TaskScheduler from Undefined.utils.sender import MessageSender +from Undefined.utils.sender import AddressBoundSender +from Undefined.utils.message_targets import DeliveryAddress logger = logging.getLogger(__name__) @@ -737,6 +739,101 @@ async def _handle_private_message(self, event: dict[str, Any]) -> None: trigger_message_id=trigger_message_id, ) + async def handle_weixin_private_message( + self, + *, + qq_id: int, + text: str, + message_content: list[dict[str, Any]], + attachments: list[dict[str, str]], + sender_name: str, + message_id: str | None, + account_alias: str, + ) -> None: + """处理已完成绑定校验的微信私聊消息。""" + if not self.config.is_private_allowed(qq_id): + return + address = DeliveryAddress("wechat", qq_id) + route_sender = AddressBoundSender(self.sender, address) + parsed_content = append_attachment_text(text, attachments) + logger.info( + "[微信私聊] 逻辑QQ=%s 帐号=%s 内容=%s", + qq_id, + account_alias, + redact_string(text)[:100], + ) + await self.history_manager.add_private_message( + user_id=qq_id, + text_content=parsed_content, + display_name=sender_name, + user_name=sender_name, + message_id=message_id, + attachments=attachments, + transport={ + "channel": "wechat", + "address": address.canonical, + "account_alias": account_alias, + }, + ) + self._schedule_meme_ingest( + attachments=attachments, + chat_type="private", + chat_id=qq_id, + sender_id=qq_id, + message_id=None, + scope_key=build_attachment_scope(user_id=qq_id, request_type="private"), + ) + if not self.config.should_process_private_message(): + return + + if ( + getattr(self.config, "model_pool_enabled", False) + and _is_private_model_pool_control_text(text) + ) and await self.ai_coordinator.model_pool.handle_private_message( + qq_id, + text, + sender=route_sender, + ): + return + + command = self.command_dispatcher.parse_command(text) + batch_scope = f"private:{address.canonical}" + if command: + await self._flush_command_buffer(scope=batch_scope, sender_id=qq_id) + + async def send_private_callback(user_id: int, message: str) -> None: + if user_id == qq_id: + await self.sender.send_address_message(address, message) + else: + await self.sender.send_private_message(user_id, message) + + await self.command_dispatcher.dispatch_private( + user_id=qq_id, + sender_id=qq_id, + command=command, + send_private_callback=send_private_callback, + ) + return + + await self._run_pipelines( + target_id=qq_id, + target_type="private", + text=text, + message_content=message_content, + address=address, + ) + await self.ai_coordinator.handle_private_reply( + qq_id, + text, + message_content, + attachments=attachments, + sender_name=sender_name, + trigger_message_id=message_id, + channel="wechat", + address=address.canonical, + batch_scope=batch_scope, + ) + async def _handle_group_message(self, event: dict[str, Any]) -> None: """处理群聊消息事件。""" group_id: int = event.get("group_id", 0) @@ -984,6 +1081,7 @@ async def _run_pipelines( target_type: Literal["group", "private"], text: str, message_content: list[dict[str, Any]], + address: DeliveryAddress | None = None, ) -> bool: """并行检测并处理所有命中的自动处理管线。""" if not getattr(self, "_pipelines_initialized", False): @@ -994,6 +1092,7 @@ async def _run_pipelines( target_type=target_type, text=text, message_content=message_content, + address=address, ) detections = await self.pipeline_registry.run(context) return bool(detections) diff --git a/src/Undefined/main.py b/src/Undefined/main.py index edd04dd9..002dbeea 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -24,6 +24,7 @@ from Undefined.onebot import OneBotClient from Undefined.api import RuntimeAPIContext, RuntimeAPIServer from Undefined.token_usage_storage import TokenUsageStorage +from Undefined.weixin import WeixinService from Undefined.utils.paths import ( CACHE_DIR, DATA_DIR, @@ -179,6 +180,7 @@ async def main() -> None: meme_job_queue = None retrieval_runtime = None runtime_api_server: RuntimeAPIServer | None = None + weixin_service: WeixinService | None = None _reranker: Any = None try: init_start = time.perf_counter() @@ -364,6 +366,12 @@ async def main() -> None: handler = MessageHandler(config, onebot, ai, faq_storage, task_storage) await handler.initialize() + weixin_service = WeixinService( + config, + message_handler=handler, + attachment_registry=ai.attachment_registry, + ) + handler.sender.set_weixin_service(weixin_service) onebot.set_message_handler(handler.handle_message) elapsed = time.perf_counter() - init_start logger.info("[初始化] 核心组件加载完成: elapsed=%.3fs", elapsed) @@ -435,6 +443,9 @@ def _apply_config_updates( config.skills_hot_reload_debounce, ) + if weixin_service is not None: + await weixin_service.start() + if config.api.enabled: # Naga 外部网关集成(需同时开启 nagaagent_mode_enabled 和 naga.enabled) naga_store = None @@ -461,6 +472,7 @@ def _apply_config_updates( naga_store=naga_store, message_batcher=handler.message_batcher, pipeline_registry=handler.pipeline_registry, + weixin_service=weixin_service, ) runtime_api_server = RuntimeAPIServer( runtime_api_context, @@ -492,12 +504,17 @@ def _apply_config_updates( logger.exception("[异常] 运行期间发生未捕获的错误: %s", exc) finally: logger.info("[清理] 正在关闭机器人并释放资源...") + if runtime_api_server is not None: + await runtime_api_server.stop() + if weixin_service is not None: + try: + await weixin_service.stop() + except Exception: + logger.exception("[清理] WeixinService stop 失败") try: await handler.close() except Exception: logger.exception("[清理] MessageHandler close 失败") - if runtime_api_server is not None: - await runtime_api_server.stop() if meme_worker is not None: await meme_worker.stop() if historian_worker: diff --git a/src/Undefined/memes/search.py b/src/Undefined/memes/search.py index abd2be24..c1de5703 100644 --- a/src/Undefined/memes/search.py +++ b/src/Undefined/memes/search.py @@ -14,7 +14,7 @@ MemeRecord, MemeSearchItem, ) -from Undefined.utils.message_targets import resolve_message_target +from Undefined.utils.message_targets import resolve_delivery_address from Undefined.utils.coerce import safe_int if TYPE_CHECKING: @@ -415,11 +415,9 @@ async def send_meme_by_uid(self, uid: str, context: dict[str, Any]) -> str: "target_type": context.get("target_type"), "target_id": context.get("target_id"), } - target, target_error = resolve_message_target(tool_args, context) + target, target_error = resolve_delivery_address(tool_args, context) if target_error or target is None: return f"发送失败:{target_error or '无法确定目标会话'}" - target_type, target_id = target - local_path = Path(record.blob_path) if not local_path.is_file(): return f"发送失败:表情包文件不存在:{uid}" @@ -433,22 +431,33 @@ async def send_meme_by_uid(self, uid: str, context: dict[str, Any]) -> str: else None ) - if target_type == "group": + preferred_temp_group_id = safe_int(context.get("group_id")) or None + send_address_message = getattr(sender, "send_address_message", None) + if callable(send_address_message): + sent_message_id = await send_address_message( + target, + cq_message, + preferred_temp_group_id=preferred_temp_group_id, + history_message=history_message, + attachments=history_attachments, + ) + elif target.channel == "group": sent_message_id = await sender.send_group_message( - int(target_id), + target.target_id, cq_message, history_message=history_message, attachments=history_attachments, ) - else: - preferred_temp_group_id = safe_int(context.get("group_id")) or None + elif target.channel == "qq": sent_message_id = await sender.send_private_message( - int(target_id), + target.target_id, cq_message, preferred_temp_group_id=preferred_temp_group_id, history_message=history_message, attachments=history_attachments, ) + else: + return "发送失败:当前 sender 不支持微信投递地址" now = _now_iso() updated_record = await self._store.increment_use(uid, now) diff --git a/src/Undefined/memes/service.py b/src/Undefined/memes/service.py index 1f7ba719..dee6c59c 100644 --- a/src/Undefined/memes/service.py +++ b/src/Undefined/memes/service.py @@ -33,7 +33,7 @@ ) from Undefined.memes.store import MemeStore from Undefined.memes.vector_store import MemeVectorStore -from Undefined.utils.message_targets import resolve_message_target +from Undefined.utils.message_targets import resolve_delivery_address from Undefined.utils.coerce import safe_int from Undefined.utils.paths import ensure_dir @@ -627,11 +627,9 @@ async def send_meme_by_uid(self, uid: str, context: dict[str, Any]) -> str: "target_type": context.get("target_type"), "target_id": context.get("target_id"), } - target, target_error = resolve_message_target(tool_args, context) + target, target_error = resolve_delivery_address(tool_args, context) if target_error or target is None: return f"发送失败:{target_error or '无法确定目标会话'}" - target_type, target_id = target - local_path = Path(record.blob_path) if not local_path.is_file(): return f"发送失败:表情包文件不存在:{uid}" @@ -645,22 +643,33 @@ async def send_meme_by_uid(self, uid: str, context: dict[str, Any]) -> str: else None ) - if target_type == "group": + preferred_temp_group_id = safe_int(context.get("group_id")) or None + send_address_message = getattr(sender, "send_address_message", None) + if callable(send_address_message): + sent_message_id = await send_address_message( + target, + cq_message, + preferred_temp_group_id=preferred_temp_group_id, + history_message=history_message, + attachments=history_attachments, + ) + elif target.channel == "group": sent_message_id = await sender.send_group_message( - int(target_id), + target.target_id, cq_message, history_message=history_message, attachments=history_attachments, ) - else: - preferred_temp_group_id = safe_int(context.get("group_id")) or None + elif target.channel == "qq": sent_message_id = await sender.send_private_message( - int(target_id), + target.target_id, cq_message, preferred_temp_group_id=preferred_temp_group_id, history_message=history_message, attachments=history_attachments, ) + else: + return "发送失败:当前 sender 不支持微信投递地址" now = _now_iso() updated_record = await self._store.increment_use(uid, now) diff --git a/src/Undefined/scheduled_task_storage.py b/src/Undefined/scheduled_task_storage.py index 32f33a03..a817fce9 100644 --- a/src/Undefined/scheduled_task_storage.py +++ b/src/Undefined/scheduled_task_storage.py @@ -35,6 +35,7 @@ class ScheduledTask: current_executions: int = 0 created_at: str = "" context_id: Optional[str] = None + address: Optional[str] = None # 新增字段:多工具调用支持 tools: Optional[list[ToolCall]] = None execution_mode: str = "serial" # serial: 串行执行, parallel: 并行执行 diff --git a/src/Undefined/services/coordinator/batching.py b/src/Undefined/services/coordinator/batching.py index 0b5a1196..e3c496c1 100644 --- a/src/Undefined/services/coordinator/batching.py +++ b/src/Undefined/services/coordinator/batching.py @@ -111,6 +111,9 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "trigger_message_id": last.trigger_message_id, "message_ids": message_ids, "batched_count": len(items), + "channel": first.channel, + "address": first.address, + "batch_scope": first.scope, } if first.batch_token is not None: request_data["_message_batcher_token"] = first.batch_token diff --git a/src/Undefined/services/coordinator/group.py b/src/Undefined/services/coordinator/group.py index e34488f9..f5536454 100644 --- a/src/Undefined/services/coordinator/group.py +++ b/src/Undefined/services/coordinator/group.py @@ -19,6 +19,7 @@ escape_xml_attr, escape_xml_text_preserving_attachment_tags, ) +from Undefined.utils.message_targets import DeliveryAddress if TYPE_CHECKING: from Undefined.config import Config @@ -329,15 +330,30 @@ async def _handle_injection_response( text: str, is_private: bool = False, sender_id: Optional[int] = None, + address: DeliveryAddress | None = None, ) -> None: """当检测到注入攻击时,生成并发送特定的防御性回复""" reply = await self.security.generate_injection_response(text) if not reply.strip(): return if is_private: - await self.sender.send_private_message(tid, reply, auto_history=False) + resolved_address = address or DeliveryAddress("qq", tid) + await self.sender.send_address_message( + resolved_address, reply, auto_history=False + ) await self.history_manager.add_private_message( - tid, "<对注入消息的回复>", "Bot", "Bot" + tid, + "<对注入消息的回复>", + "Bot", + "Bot", + transport=( + { + "channel": resolved_address.channel, + "address": resolved_address.canonical, + } + if resolved_address.channel == "wechat" + else None + ), ) else: msg = f"[@{sender_id}] {reply}" if sender_id else reply diff --git a/src/Undefined/services/coordinator/private.py b/src/Undefined/services/coordinator/private.py index bc65d9ab..9bff7b9f 100644 --- a/src/Undefined/services/coordinator/private.py +++ b/src/Undefined/services/coordinator/private.py @@ -7,6 +7,7 @@ import logging import time from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING, Any from Undefined.attachments import ( @@ -20,6 +21,8 @@ from Undefined.render import render_html_to_image, render_markdown_to_html from Undefined.services.message_batcher import BufferedMessage, make_scope from Undefined.utils.recent_messages import get_recent_messages_prefer_local +from Undefined.utils.message_targets import DeliveryAddress, parse_delivery_address +from Undefined.utils.sender import AddressBoundSender from Undefined.utils.xml import ( escape_xml_attr, escape_xml_text_preserving_attachment_tags, @@ -66,6 +69,7 @@ async def _handle_injection_response( text: str, is_private: bool = False, sender_id: int | None = None, + address: DeliveryAddress | None = None, ) -> None: ... async def _send_image(self, tid: int, mtype: str, path: str) -> None: ... @@ -77,7 +81,10 @@ async def handle_private_reply( attachments: list[dict[str, str]] | None = None, is_poke: bool = False, sender_name: str = "未知用户", - trigger_message_id: int | None = None, + trigger_message_id: int | str | None = None, + channel: str = "qq", + address: str | None = None, + batch_scope: str | None = None, ) -> None: """处理私聊消息入口,决定回复策略并进行安全检测""" logger.debug("[私聊回复] user=%s text_len=%s", user_id, len(text)) @@ -87,10 +94,34 @@ async def handle_private_reply( await self.history_manager.modify_last_private_message( user_id, "<这句话检测到用户进行注入,已删除>" ) - await self._handle_injection_response(user_id, text, is_private=True) + resolved, error = parse_delivery_address( + address or f"{channel}:{user_id}" + ) + if error or resolved is None: + raise ValueError(error or "私聊投递地址无效") + await self._handle_injection_response( + user_id, + text, + is_private=True, + address=resolved, + ) return - scope = make_scope(user_id=user_id) + resolved_address, address_error = parse_delivery_address( + address or f"{channel}:{user_id}" + ) + if address_error or resolved_address is None: + raise ValueError(address_error or "私聊投递地址无效") + if ( + resolved_address.target_type != "private" + or resolved_address.target_id != user_id + ): + raise ValueError("私聊投递地址与逻辑 QQ 身份不一致") + scope = batch_scope or ( + make_scope(user_id=user_id) + if resolved_address.channel == "qq" + else f"private:{resolved_address.canonical}" + ) item = BufferedMessage( scope=scope, sender_id=user_id, @@ -102,6 +133,8 @@ async def handle_private_reply( is_private=True, trigger_message_id=trigger_message_id, is_poke=is_poke, + channel=resolved_address.channel, + address=resolved_address.canonical, ) if is_poke: @@ -126,17 +159,27 @@ async def _execute_private_reply(self, request: dict[str, Any]) -> None: for item in request.get("message_ids", []) if str(item).strip() ] - batcher_scope: str | None = make_scope(user_id=user_id) + address, address_error = parse_delivery_address( + request.get("address") or f"qq:{user_id}" + ) + if address_error or address is None: + raise ValueError(address_error or "私聊投递地址无效") + if address.target_type != "private" or address.target_id != user_id: + raise ValueError("私聊投递地址与逻辑 QQ 身份不一致") + channel = address.channel + batcher_scope = str(request.get("batch_scope") or make_scope(user_id=user_id)) async with RequestContext( request_type="private", user_id=user_id, sender_id=user_id, + channel=channel, + address=address.canonical, ) as ctx: async def send_msg_cb(message: str, reply_to: int | None = None) -> None: - await self.sender.send_private_message( - user_id, message, reply_to=reply_to + await self.sender.send_address_message( + address, message, reply_to=reply_to ) async def get_recent_cb( @@ -154,6 +197,33 @@ async def get_recent_cb( ) async def send_img_cb(tid: int, mtype: str, path: str) -> None: + if ( + address.channel == "wechat" + and mtype == "private" + and tid == user_id + ): + suffix = Path(path).suffix.lower() + if suffix in { + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ".webp", + }: + kind = "image" + elif suffix in {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"}: + kind = "voice" + else: + return + await self.sender.send_address_file( + address, + path, + name=Path(path).name, + kind=kind, + auto_history=False, + ) + return await self._send_image(tid, mtype, path) async def send_like_cb(uid: int, times: int = 1) -> None: @@ -162,12 +232,27 @@ async def send_like_cb(uid: int, times: int = 1) -> None: async def send_private_cb( uid: int, msg: str, reply_to: int | None = None ) -> None: - await self.sender.send_private_message(uid, msg, reply_to=reply_to) + if uid == user_id: + await self.sender.send_address_message( + address, + msg, + reply_to=reply_to, + ) + else: + await self.sender.send_private_message( + uid, + msg, + reply_to=reply_to, + ) ai_client = self.ai memory_storage = self.ai.memory_storage runtime_config = self.ai.runtime_config - sender = self.sender + sender = ( + AddressBoundSender(self.sender, address) + if address.channel == "wechat" + else self.sender + ) history_manager = self.history_manager onebot_client = self.onebot scheduler = self.scheduler @@ -184,6 +269,8 @@ async def send_private_cb( for key, value in resources.items(): if value is not None: ctx.set_resource(key, value) + ctx.set_resource("channel", channel) + ctx.set_resource("address", address.canonical) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) if message_ids: @@ -207,7 +294,18 @@ async def send_private_cb( ): batcher.register_inflight(batcher_scope, user_id, current_task, ctx) registered_task = current_task + typing_started = False try: + if address.channel == "wechat": + try: + await self.sender.set_address_typing(address, True) + typing_started = True + except Exception: + logger.debug( + "[微信] 设置输入状态失败: address=%s", + address.canonical, + exc_info=True, + ) result = await self.ai.ask( full_question, send_message_callback=send_msg_cb, @@ -215,7 +313,7 @@ async def send_private_cb( get_image_url_callback=self.onebot.get_image, get_forward_msg_callback=self.onebot.get_forward_msg, send_like_callback=send_like_cb, - sender=self.sender, + sender=sender, history_manager=self.history_manager, onebot_client=self.onebot, scheduler=self.scheduler, @@ -232,9 +330,20 @@ async def send_private_cb( request.get("batched_count", 1) or 1 ) > 1, + "channel": channel, + "address": address.canonical, }, ) finally: + if typing_started: + try: + await self.sender.set_address_typing(address, False) + except Exception: + logger.debug( + "[微信] 取消输入状态失败: address=%s", + address.canonical, + exc_info=True, + ) if ( batcher is not None and batcher_scope is not None @@ -254,10 +363,11 @@ async def send_private_cb( scope_key=scope_key, strict=False, ) - await self.sender.send_private_message( - user_id, + await self.sender.send_address_message( + address, rendered.delivery_text, history_message=rendered.history_text, + attachments=list(rendered.attachments), ) await dispatch_pending_file_sends( rendered, @@ -265,6 +375,7 @@ async def send_private_cb( target_type="private", target_id=user_id, registry=self.ai.attachment_registry, + address=address, ) except asyncio.CancelledError: logger.info("[私聊回复] 任务被取消(投机抢占): user=%s", user_id) @@ -281,6 +392,8 @@ def _format_private_message_segment(self, item: BufferedMessage) -> str: safe_name = escape_xml_attr(item.sender_name or "未知用户") safe_uid = escape_xml_attr(item.sender_id) safe_time = escape_xml_attr(time_str) + safe_channel = escape_xml_attr(item.channel) + safe_address = escape_xml_attr(item.address) safe_text = escape_xml_text_preserving_attachment_tags( item.text, item.attachments, @@ -293,9 +406,14 @@ def _format_private_message_segment(self, item: BufferedMessage) -> str: attachment_xml = ( f"\n{attachment_refs_to_xml(item.attachments)}" if item.attachments else "" ) + route_attrs = "" + location = "私聊" + if item.channel == "wechat": + route_attrs = f' channel="{safe_channel}" address="{safe_address}"' + location = "微信私聊" return ( f'\n' + f'{route_attrs.lstrip()} location="{location}" time="{safe_time}">\n' f" {safe_text}{attachment_xml}\n" f" " ) diff --git a/src/Undefined/services/message_batcher/state.py b/src/Undefined/services/message_batcher/state.py index 6dbea7c4..679860d7 100644 --- a/src/Undefined/services/message_batcher/state.py +++ b/src/Undefined/services/message_batcher/state.py @@ -36,7 +36,9 @@ class BufferedMessage: sender_name: str arrival_time: float is_private: bool - trigger_message_id: int | None = None + trigger_message_id: int | str | None = None + channel: str = "qq" + address: str = "" is_poke: bool = False is_at_bot: bool = False is_fake_at: bool = False diff --git a/src/Undefined/services/model_pool.py b/src/Undefined/services/model_pool.py index d075c768..41cd8cf8 100644 --- a/src/Undefined/services/model_pool.py +++ b/src/Undefined/services/model_pool.py @@ -35,16 +35,25 @@ def is_private_control_text(text: str) -> bool: or _SELECT_COMMAND_RE.fullmatch(stripped) ) - async def handle_private_message(self, user_id: int, text: str) -> bool: + async def handle_private_message( + self, + user_id: int, + text: str, + *, + sender: Any | None = None, + ) -> bool: """处理私聊多模型指令,返回 True 表示消息已被消费""" if not self._config.model_pool_enabled: return False + resolved_sender = sender or self._sender stripped = text.strip() compare_match = _COMPARE_COMMAND_RE.fullmatch(stripped) if compare_match: await self._run_compare( - user_id, (compare_match.group("prompt") or "").strip() + user_id, + (compare_match.group("prompt") or "").strip(), + sender=resolved_sender, ) return True @@ -54,28 +63,26 @@ async def handle_private_message(self, user_id: int, text: str) -> bool: if selected: selector.set_preference(0, user_id, "chat", selected) await selector.save_preferences() - await self._sender.send_private_message( + await resolved_sender.send_private_message( user_id, f"已切换到模型: {selected}" ) return True return False - async def _run_compare(self, user_id: int, prompt: str) -> None: + async def _run_compare(self, user_id: int, prompt: str, *, sender: Any) -> None: if not prompt: - await self._sender.send_private_message(user_id, "用法: /compare <问题>") + await sender.send_private_message(user_id, "用法: /compare <问题>") return selector = self._ai.model_selector all_models = selector.get_all_chat_models(self._config.chat_model) if len(all_models) < 2: - await self._sender.send_private_message( - user_id, "模型池中只有一个模型,无法比较" - ) + await sender.send_private_message(user_id, "模型池中只有一个模型,无法比较") return - await self._sender.send_private_message( + await sender.send_private_message( user_id, f"正在向 {len(all_models)} 个模型发送问题,请稍候..." ) @@ -105,7 +112,7 @@ async def _query(name: str, cfg: ChatModelConfig) -> tuple[str, str]: lines += [f"【{i}】{name}", content, ""] lines.append("回复「选X」可切换到该模型并继续对话") - await self._sender.send_private_message(user_id, "\n".join(lines)) + await sender.send_private_message(user_id, "\n".join(lines)) selector.set_pending_compare(0, user_id, [n for n, _ in results]) def select_chat_config( diff --git a/src/Undefined/skills/pipelines/arxiv/handler.py b/src/Undefined/skills/pipelines/arxiv/handler.py index 8dcf961e..25b50cb1 100644 --- a/src/Undefined/skills/pipelines/arxiv/handler.py +++ b/src/Undefined/skills/pipelines/arxiv/handler.py @@ -35,8 +35,12 @@ async def process( context: PipelineContext, ) -> None: handler = context["handle_arxiv_extract"] - await handler( + args = ( int(context["target_id"]), list(detection.items), str(context["target_type"]), ) + if context.get("address") is None: + await handler(*args) + else: + await handler(*args, context["sender"]) diff --git a/src/Undefined/skills/pipelines/bilibili/handler.py b/src/Undefined/skills/pipelines/bilibili/handler.py index 3e60c617..9fe308c5 100644 --- a/src/Undefined/skills/pipelines/bilibili/handler.py +++ b/src/Undefined/skills/pipelines/bilibili/handler.py @@ -35,8 +35,12 @@ async def process( context: PipelineContext, ) -> None: handler = context["handle_bilibili_extract"] - await handler( + args = ( int(context["target_id"]), list(detection.items), str(context["target_type"]), ) + if context.get("address") is None: + await handler(*args) + else: + await handler(*args, context["sender"]) diff --git a/src/Undefined/skills/pipelines/context.py b/src/Undefined/skills/pipelines/context.py index 18391c68..065bbf2b 100644 --- a/src/Undefined/skills/pipelines/context.py +++ b/src/Undefined/skills/pipelines/context.py @@ -4,6 +4,9 @@ from typing import Any +from Undefined.utils.message_targets import DeliveryAddress +from Undefined.utils.sender import AddressBoundSender + def build_pipeline_context( handler: Any, @@ -12,13 +15,18 @@ def build_pipeline_context( target_type: str, text: str, message_content: list[dict[str, Any]] | None, + address: DeliveryAddress | None = None, ) -> dict[str, Any]: + sender = handler.sender + if address is not None and address.channel == "wechat": + sender = AddressBoundSender(handler.sender, address) return { "config": handler.config, - "sender": handler.sender, + "sender": sender, "onebot": handler.onebot, "target_id": target_id, "target_type": target_type, + "address": address, "text": text, "message_content": message_content, "extract_bilibili_ids": handler._extract_bilibili_ids, diff --git a/src/Undefined/skills/pipelines/douyin/handler.py b/src/Undefined/skills/pipelines/douyin/handler.py index 1a6dc405..a47cef9c 100644 --- a/src/Undefined/skills/pipelines/douyin/handler.py +++ b/src/Undefined/skills/pipelines/douyin/handler.py @@ -34,8 +34,12 @@ async def process( context: PipelineContext, ) -> None: handler = context["handle_douyin_extract"] - await handler( + args = ( int(context["target_id"]), list(detection.items), str(context["target_type"]), ) + if context.get("address") is None: + await handler(*args) + else: + await handler(*args, context["sender"]) diff --git a/src/Undefined/skills/pipelines/github/handler.py b/src/Undefined/skills/pipelines/github/handler.py index 2148d49c..46998858 100644 --- a/src/Undefined/skills/pipelines/github/handler.py +++ b/src/Undefined/skills/pipelines/github/handler.py @@ -35,8 +35,12 @@ async def process( context: PipelineContext, ) -> None: handler = context["handle_github_extract"] - await handler( + args = ( int(context["target_id"]), list(detection.items), str(context["target_type"]), ) + if context.get("address") is None: + await handler(*args) + else: + await handler(*args, context["sender"]) diff --git a/src/Undefined/skills/toolsets/messages/send_message/config.json b/src/Undefined/skills/toolsets/messages/send_message/config.json index 397966c6..42adcb82 100644 --- a/src/Undefined/skills/toolsets/messages/send_message/config.json +++ b/src/Undefined/skills/toolsets/messages/send_message/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "send_message", - "description": "发送消息到群聊或私聊。默认发送到当前会话;也可通过 target_type+target_id 指定目标群号或用户QQ。会受到访问控制白名单限制。可以在回答过程中多次调用,用于发送中间结果、进展信息或最终答案。最后必须调用 end 工具结束对话。成功时工具结果会尽量返回新消息的 message_id,便于后续再次用 reply_to 引用任意已知 message_id 的消息,包括当前消息、历史消息、别人发的消息或 Bot 之前发出的消息。\n群聊中 @ 某人:在消息里写 [@QQ号],例如 [@2608261902] 你好。注意方括号内直接跟QQ号,不要加花括号。用 \\[@...\\] 转义可避免触发@。", + "description": "发送消息。默认回复当前物理会话;可用 address 精确指定 qq:、group:<群号> 或 wechat:<逻辑QQ号>。旧 target_type+target_id 参数继续兼容并表示 QQ/QQ群。会受到逻辑 QQ/群访问控制。微信通道不支持 reply_to。可以在回答过程中多次调用,最后必须调用 end。\n群聊中 @ 某人:在消息里写 [@QQ号],例如 [@2608261902] 你好。", "parameters": { "type": "object", "properties": { @@ -10,6 +10,11 @@ "type": "string", "description": "要发送的消息内容。群聊中用 [@QQ号] 来 @ 用户,如 [@2608261902]。" }, + "address": { + "type": "string", + "pattern": "^(qq|group|wechat):[1-9][0-9]*$", + "description": "可选。规范投递地址:qq:、group:<群号>、wechat:<逻辑QQ号>。不填时使用当前会话地址。不要与旧目标参数混用。" + }, "target_type": { "type": "string", "enum": ["group", "private"], diff --git a/src/Undefined/skills/toolsets/messages/send_message/handler.py b/src/Undefined/skills/toolsets/messages/send_message/handler.py index 507faefa..12fd5c06 100644 --- a/src/Undefined/skills/toolsets/messages/send_message/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_message/handler.py @@ -6,8 +6,12 @@ render_message_with_pic_placeholders, scope_from_context, ) -from Undefined.utils.message_targets import TargetType, parse_positive_int -from Undefined.utils.message_targets import resolve_message_target +from Undefined.utils.message_targets import ( + DeliveryAddress, + parse_delivery_address, + parse_positive_int, + resolve_delivery_address, +) from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -15,8 +19,8 @@ def _resolve_target( args: Dict[str, Any], context: Dict[str, Any] -) -> tuple[tuple[TargetType, int] | None, str | None]: - return resolve_message_target(args, context) +) -> tuple[DeliveryAddress | None, str | None]: + return resolve_delivery_address(args, context) def _is_current_group_target(context: Dict[str, Any], target_id: int) -> bool: @@ -124,14 +128,16 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: ) return f"发送失败:{target_error or '目标参数错误'}" - target_type, target_id = target + target_type, target_id = target.target_type, target.target_id logger.debug( - "[发送消息] request_id=%s target_type=%s target_id=%s", + "[发送消息] request_id=%s address=%s", request_id, - target_type, - target_id, + target.canonical, ) + if target.channel == "wechat" and reply_to_id is not None: + return "发送失败:微信 iLink 暂不支持引用回复(reply_to)" + if runtime_config is not None: if target_type == "group" and not runtime_config.is_group_allowed(target_id): return _group_access_error(runtime_config, target_id) @@ -142,33 +148,37 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if sender: try: - if target_type == "group": - logger.info("[发送消息] 准备发送到群 %s: %s", target_id, message[:100]) - send_kwargs: dict[str, Any] = { - "reply_to": reply_to_id, - "history_message": history_message, - } - if history_attachments: - send_kwargs["attachments"] = history_attachments + logger.info("[发送消息] 准备发送到 %s: %s", target.canonical, message[:100]) + send_kwargs: dict[str, Any] = { + "reply_to": reply_to_id, + "preferred_temp_group_id": _get_context_group_id(context), + "history_message": history_message, + } + if history_attachments: + send_kwargs["attachments"] = history_attachments + send_address_message = getattr(sender, "send_address_message", None) + if callable(send_address_message): + sent_message_id = await send_address_message( + target, + message, + **send_kwargs, + ) + elif target.channel == "group": + group_kwargs = dict(send_kwargs) + group_kwargs.pop("preferred_temp_group_id", None) sent_message_id = await sender.send_group_message( target_id, message, - **send_kwargs, + **group_kwargs, ) - else: - logger.info("[发送消息] 准备发送私聊 %s: %s", target_id, message[:100]) - send_kwargs = { - "reply_to": reply_to_id, - "preferred_temp_group_id": _get_context_group_id(context), - "history_message": history_message, - } - if history_attachments: - send_kwargs["attachments"] = history_attachments + elif target.channel == "qq": sent_message_id = await sender.send_private_message( target_id, message, **send_kwargs, ) + else: + raise RuntimeError("当前 sender 不支持微信投递地址") mark_message_sent(context) await dispatch_pending_file_sends( rendered, @@ -176,6 +186,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: target_type=target_type, target_id=target_id, registry=attachment_registry, + address=target, ) return _format_send_success(sent_message_id) except Exception as e: @@ -211,7 +222,12 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: ) return "发送失败:当前环境无法发送到目标群聊" - if send_private_message_callback: + current_address, _ = parse_delivery_address(context.get("address")) + is_current_address = current_address == target + if target.channel == "wechat" and not is_current_address: + return "发送失败:当前环境无法发送到指定微信地址" + + if send_private_message_callback and target.channel == "qq": try: await send_private_message_callback( target_id, message, reply_to=reply_to_id @@ -227,7 +243,9 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: ) return "发送失败:消息服务暂时不可用,请稍后重试" - if send_message_callback and _is_current_private_target(context, target_id): + if send_message_callback and ( + is_current_address or _is_current_private_target(context, target_id) + ): try: await send_message_callback(message, reply_to=reply_to_id) mark_message_sent(context) diff --git a/src/Undefined/skills/toolsets/messages/send_private_message/config.json b/src/Undefined/skills/toolsets/messages/send_private_message/config.json index 6e71c611..bbca249f 100644 --- a/src/Undefined/skills/toolsets/messages/send_private_message/config.json +++ b/src/Undefined/skills/toolsets/messages/send_private_message/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "send_private_message", - "description": "发送私聊消息给指定用户。可在群聊或私聊会话中使用;若未提供 user_id,默认使用当前会话用户。会受到访问控制白名单限制。最后必须调用 end 工具结束对话。成功时工具结果会尽量返回新消息的 message_id,便于后续再次用 reply_to 引用任意已知 message_id 的消息,包括当前消息、历史消息、别人发的消息或 Bot 之前发出的消息。", + "description": "发送私聊消息。默认使用当前物理私聊;可用 address 指定 qq: 或 wechat:<逻辑QQ号>。旧 user_id 参数继续兼容并表示 QQ 私聊。微信不支持 reply_to。会受到逻辑 QQ 访问控制,最后必须调用 end。", "parameters": { "type": "object", "properties": { @@ -10,6 +10,11 @@ "type": "integer", "description": "可选。目标用户 QQ 号;不填时默认当前会话用户。" }, + "address": { + "type": "string", + "pattern": "^(qq|wechat):[1-9][0-9]*$", + "description": "可选。私聊投递地址:qq: 或 wechat:<逻辑QQ号>。不填时使用当前会话地址;不要与 user_id 混用。" + }, "message": { "type": "string", "description": "要发送的私聊消息内容" diff --git a/src/Undefined/skills/toolsets/messages/send_private_message/handler.py b/src/Undefined/skills/toolsets/messages/send_private_message/handler.py index e224f2f8..bf39fb14 100644 --- a/src/Undefined/skills/toolsets/messages/send_private_message/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_private_message/handler.py @@ -7,6 +7,10 @@ scope_from_context, ) from Undefined.skills.toolsets.messages.context_utils import mark_message_sent +from Undefined.utils.message_targets import ( + parse_delivery_address, + resolve_delivery_address, +) logger = logging.getLogger(__name__) @@ -60,22 +64,21 @@ def _format_send_success(user_id: int, message_id: Any) -> str: async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: """向指定用户发送私聊消息""" request_id = str(context.get("request_id", "-")) - user_id_raw = args.get("user_id") - if user_id_raw is None: - user_id_raw = context.get("user_id") - - user_id, user_error = _parse_positive_int(user_id_raw, "user_id") + target, target_error = resolve_delivery_address(args, context) + if target_error or target is None: + return f"发送失败:{target_error or '无法确定目标私聊'}" + if target.target_type != "private": + return "发送失败:send_private_message 不支持群聊地址" + user_id = target.target_id message = str(args.get("message", "")) # 解析 reply_to 参数(无效值静默忽略,视为未传) reply_to_id, _ = _parse_positive_int(args.get("reply_to"), "reply_to") - if user_error: - return f"发送失败:{user_error}" - if user_id is None: - return "目标用户 QQ 号不能为空" if not message: return "消息内容不能为空" + if target.channel == "wechat" and reply_to_id is not None: + return "发送失败:微信 iLink 暂不支持引用回复(reply_to)" attachment_registry = context.get("attachment_registry") scope_key = scope_from_context(context) @@ -111,11 +114,21 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: } if history_attachments: send_kwargs["attachments"] = history_attachments - sent_message_id = await sender.send_private_message( - user_id, - message, - **send_kwargs, - ) + send_address_message = getattr(sender, "send_address_message", None) + if callable(send_address_message): + sent_message_id = await send_address_message( + target, + message, + **send_kwargs, + ) + elif target.channel == "qq": + sent_message_id = await sender.send_private_message( + user_id, + message, + **send_kwargs, + ) + else: + raise RuntimeError("当前 sender 不支持微信投递地址") mark_message_sent(context) await dispatch_pending_file_sends( rendered, @@ -123,6 +136,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: target_type="private", target_id=user_id, registry=attachment_registry, + address=target, ) return _format_send_success(user_id, sent_message_id) except Exception as e: @@ -134,7 +148,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: ) return "发送失败:消息服务暂时不可用,请稍后重试" - if send_private_message_callback: + if send_private_message_callback and target.channel == "qq": try: await send_private_message_callback(user_id, message, reply_to=reply_to_id) mark_message_sent(context) @@ -148,5 +162,21 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: ) return "发送失败:消息服务暂时不可用,请稍后重试" + send_message_callback = context.get("send_message_callback") + current_address, _ = parse_delivery_address(context.get("address")) + if send_message_callback and current_address == target: + try: + await send_message_callback(message, reply_to=reply_to_id) + mark_message_sent(context) + return f"私聊消息已发送给用户 {user_id}" + except Exception as exc: + logger.exception( + "[私聊发送] 当前会话回调失败: address=%s request_id=%s err=%s", + target.canonical, + request_id, + exc, + ) + return "发送失败:消息服务暂时不可用,请稍后重试" + logger.error("[私聊发送] 发送通道未设置: request_id=%s", request_id) return "私聊发送回调未设置" diff --git a/src/Undefined/skills/toolsets/scheduler/README.md b/src/Undefined/skills/toolsets/scheduler/README.md index bde07465..6f1ab18b 100644 --- a/src/Undefined/skills/toolsets/scheduler/README.md +++ b/src/Undefined/skills/toolsets/scheduler/README.md @@ -6,6 +6,7 @@ - 创建/更新/删除定时任务 - 列出定时任务 - 支持“调用未来的自己”:通过 `self_instruction` 让定时任务在触发时调用 AI 自身 +- 使用统一 `address` 投递目标:`qq:`、`group:<群号>`、`wechat:<逻辑QQ号>`;省略时继承当前会话物理通道 目录结构: - 每个子目录对应一个工具(`config.json` + `handler.py`) @@ -23,6 +24,7 @@ ```json { "cron_expression": "0 9 * * *", + "address": "wechat:12345678", "self_instruction": "请总结昨天群里提到的待办,并提醒我今天优先处理前三项。" } ``` diff --git a/src/Undefined/skills/toolsets/scheduler/create_schedule_task/config.json b/src/Undefined/skills/toolsets/scheduler/create_schedule_task/config.json index 9903b076..aae4ceca 100644 --- a/src/Undefined/skills/toolsets/scheduler/create_schedule_task/config.json +++ b/src/Undefined/skills/toolsets/scheduler/create_schedule_task/config.json @@ -58,6 +58,10 @@ "max_executions": { "type": "integer", "description": "最大执行次数(可选)。设置为 1 表示执行一次后自动删除,设置为 N 表示执行 N 次后自动删除,不设置则无限执行。" + }, + "address": { + "type": "string", + "description": "可选投递地址,格式为 qq:、group:<群号> 或 wechat:<逻辑QQ号>。默认继承当前会话的物理通道。" } }, "required": [ diff --git a/src/Undefined/skills/toolsets/scheduler/create_schedule_task/handler.py b/src/Undefined/skills/toolsets/scheduler/create_schedule_task/handler.py index 9a80d009..5f4fec11 100644 --- a/src/Undefined/skills/toolsets/scheduler/create_schedule_task/handler.py +++ b/src/Undefined/skills/toolsets/scheduler/create_schedule_task/handler.py @@ -20,6 +20,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: execution_mode = args.get("execution_mode", "serial") max_executions = args.get("max_executions") self_instruction = args.get("self_instruction") + explicit_address = str(args.get("address") or "").strip() # 验证参数 if not cron_expression: @@ -89,6 +90,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if not target_type: target_type = "group" + target_address = explicit_address or str(context.get("address") or "").strip() resolved_tool_name = tool_name resolved_tool_args = tool_args @@ -107,6 +109,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: cron_expression=cron_expression, target_id=target_id, target_type=target_type, + target_address=target_address or None, task_name=task_name, max_executions=max_executions, tools=resolved_tools, diff --git a/src/Undefined/skills/toolsets/scheduler/list_schedule_tasks/handler.py b/src/Undefined/skills/toolsets/scheduler/list_schedule_tasks/handler.py index 393a59ae..2b6a8fe2 100644 --- a/src/Undefined/skills/toolsets/scheduler/list_schedule_tasks/handler.py +++ b/src/Undefined/skills/toolsets/scheduler/list_schedule_tasks/handler.py @@ -31,6 +31,10 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: ).strip() max_exec = info.get("max_executions") current_exec = info.get("current_executions", 0) + address = str(info.get("address") or "").strip() + if not address and info.get("target_id"): + channel = "group" if info.get("target_type") == "group" else "qq" + address = f"{channel}:{info['target_id']}" exec_info = "" if max_exec is not None: @@ -50,6 +54,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: lines.append(f" 名称: {name_display}") lines.append(f" 工具: {tool_display}") lines.append(f" 表达式: {cron}") + lines.append(f" 投递地址: {address or '未指定'}") lines.append(f" 参数: {args_str}") lines.append(f" 已执行: {exec_info}") lines.append("") diff --git a/src/Undefined/skills/toolsets/scheduler/update_schedule_task/config.json b/src/Undefined/skills/toolsets/scheduler/update_schedule_task/config.json index 72809ca1..547c5184 100644 --- a/src/Undefined/skills/toolsets/scheduler/update_schedule_task/config.json +++ b/src/Undefined/skills/toolsets/scheduler/update_schedule_task/config.json @@ -62,6 +62,10 @@ "max_executions": { "type": "integer", "description": "新的最大执行次数(可选)。设置为 1 表示执行一次后自动删除,设置为 N 表示执行 N 次后自动删除,不设置则保持原值。" + }, + "address": { + "type": "string", + "description": "新的投递地址,格式为 qq:、group:<群号> 或 wechat:<逻辑QQ号>;设为空字符串可清空。" } }, "required": [ diff --git a/src/Undefined/skills/toolsets/scheduler/update_schedule_task/handler.py b/src/Undefined/skills/toolsets/scheduler/update_schedule_task/handler.py index d8ba287e..48738f2b 100644 --- a/src/Undefined/skills/toolsets/scheduler/update_schedule_task/handler.py +++ b/src/Undefined/skills/toolsets/scheduler/update_schedule_task/handler.py @@ -19,6 +19,8 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: task_name = args.get("task_name") max_executions = args.get("max_executions") self_instruction = args.get("self_instruction") + address_provided = "address" in args + target_address = str(args.get("address") or "").strip() or None if not task_id: return "请提供要修改的任务 ID" @@ -82,6 +84,8 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: tools=tools, execution_mode=execution_mode, self_instruction=normalized_self_instruction if has_self_instruction else None, + target_address=target_address, + target_address_provided=address_provided, ) if success: diff --git a/src/Undefined/utils/history.py b/src/Undefined/utils/history.py index 7ee33882..62b05a05 100644 --- a/src/Undefined/utils/history.py +++ b/src/Undefined/utils/history.py @@ -368,7 +368,7 @@ async def add_group_message( role: str = "member", title: str = "", level: str = "", - message_id: int | None = None, + message_id: int | str | None = None, attachments: list[dict[str, str]] | None = None, ) -> None: """异步保存群消息到历史记录""" @@ -426,9 +426,10 @@ async def add_private_message( text_content: str, display_name: str = "", user_name: str = "", - message_id: int | None = None, + message_id: int | str | None = None, attachments: list[dict[str, str]] | None = None, webchat: dict[str, Any] | None = None, + transport: dict[str, Any] | None = None, ) -> None: """异步保存私聊消息到历史记录""" await self._ensure_initialized() @@ -459,6 +460,8 @@ async def add_private_message( record["attachments"] = attachments if isinstance(webchat, dict): record["webchat"] = webchat + if isinstance(transport, dict): + record["transport"] = dict(transport) self._private_message_history[user_id_str].append(record) diff --git a/src/Undefined/utils/message_targets.py b/src/Undefined/utils/message_targets.py index c6c3debb..55670f66 100644 --- a/src/Undefined/utils/message_targets.py +++ b/src/Undefined/utils/message_targets.py @@ -1,9 +1,36 @@ +"""消息投递地址解析。 + +逻辑身份始终使用 QQ 号;``channel`` 仅描述物理投递通道。 +""" + from __future__ import annotations +from dataclasses import dataclass from typing import Any, Literal TargetType = Literal["group", "private"] +DeliveryChannel = Literal["qq", "group", "wechat"] + + +@dataclass(frozen=True, slots=True) +class DeliveryAddress: + """一个规范化的消息投递地址。""" + + channel: DeliveryChannel + target_id: int + + @property + def target_type(self) -> TargetType: + return "group" if self.channel == "group" else "private" + + @property + def canonical(self) -> str: + return f"{self.channel}:{self.target_id}" + + @property + def logical_user_id(self) -> int | None: + return self.target_id if self.target_type == "private" else None def parse_positive_int(value: Any, field_name: str) -> tuple[int | None, str | None]: @@ -18,9 +45,41 @@ def parse_positive_int(value: Any, field_name: str) -> tuple[int | None, str | N return parsed, None -def resolve_message_target( - args: dict[str, Any], context: dict[str, Any] -) -> tuple[tuple[TargetType, int] | None, str | None]: +def parse_delivery_address( + value: Any, + *, + field_name: str = "address", +) -> tuple[DeliveryAddress | None, str | None]: + """解析 ``qq:``、``group:`` 或 ``wechat:``。""" + if value is None: + return None, None + if not isinstance(value, str): + return None, f"{field_name} 必须是字符串" + text = value.strip().lower() + channel_text, separator, target_text = text.partition(":") + if not separator or not channel_text or not target_text: + return None, ( + f"{field_name} 格式错误,应为 qq:、group:<群号> 或 wechat:" + ) + if channel_text not in {"qq", "group", "wechat"}: + return None, f"{field_name} 通道只能是 qq、group 或 wechat" + target_id, error = parse_positive_int(target_text, field_name) + if error or target_id is None: + return None, error or f"{field_name} 非法" + channel: DeliveryChannel + if channel_text == "group": + channel = "group" + elif channel_text == "wechat": + channel = "wechat" + else: + channel = "qq" + return DeliveryAddress(channel=channel, target_id=target_id), None + + +def _legacy_explicit_address( + args: dict[str, Any], + context: dict[str, Any], +) -> tuple[DeliveryAddress | None, str | None, bool]: target_type_raw = args.get("target_type") target_id_raw = args.get("target_id") has_target_type = target_type_raw is not None @@ -28,83 +87,107 @@ def resolve_message_target( if has_target_type or has_target_id: if not has_target_type and has_target_id: - return None, "target_type 与 target_id 必须同时提供" - + return None, "target_type 与 target_id 必须同时提供", True if not isinstance(target_type_raw, str): - return None, "target_type 必须是字符串(group 或 private)" + return None, "target_type 必须是字符串(group 或 private)", True target_type = target_type_raw.strip().lower() if target_type not in ("group", "private"): - return None, "target_type 只能是 group 或 private" - - normalized_target_type: TargetType = ( - "group" if target_type == "group" else "private" - ) - + return None, "target_type 只能是 group 或 private", True + normalized_type: TargetType = "group" if target_type == "group" else "private" if has_target_id: - target_id, id_error = parse_positive_int(target_id_raw, "target_id") - if id_error or target_id is None: - return None, id_error or "target_id 非法" - return (normalized_target_type, target_id), None - - request_type = context.get("request_type") - if request_type != normalized_target_type: - return None, "target_type 与当前会话类型不一致,无法推断 target_id" - - if normalized_target_type == "group": - group_id, group_error = parse_positive_int( - context.get("group_id"), "group_id" - ) - if group_error or group_id is None: - return None, group_error or "无法根据 target_type 推断 target_id" - return ("group", group_id), None - - user_id, user_error = parse_positive_int(context.get("user_id"), "user_id") - if user_error or user_id is None: - return None, user_error or "无法根据 target_type 推断 target_id" - return ("private", user_id), None + target_id, error = parse_positive_int(target_id_raw, "target_id") + if error or target_id is None: + return None, error or "target_id 非法", True + channel: DeliveryChannel = "group" if normalized_type == "group" else "qq" + return DeliveryAddress(channel, target_id), None, True + + if context.get("request_type") != normalized_type: + return None, "target_type 与当前会话类型不一致,无法推断 target_id", True + id_field = "group_id" if normalized_type == "group" else "user_id" + target_id, error = parse_positive_int(context.get(id_field), id_field) + if error or target_id is None: + return None, error or "无法根据 target_type 推断 target_id", True + channel = "group" if normalized_type == "group" else "qq" + return DeliveryAddress(channel, target_id), None, True legacy_group_id = args.get("group_id") if legacy_group_id is not None: - group_id, group_error = parse_positive_int(legacy_group_id, "group_id") - if group_error or group_id is None: - return None, group_error or "group_id 非法" - return ("group", group_id), None + group_id, error = parse_positive_int(legacy_group_id, "group_id") + if error or group_id is None: + return None, error or "group_id 非法", True + return DeliveryAddress("group", group_id), None, True legacy_user_id = args.get("user_id") if legacy_user_id is not None: - user_id, user_error = parse_positive_int(legacy_user_id, "user_id") - if user_error or user_id is None: - return None, user_error or "user_id 非法" - return ("private", user_id), None + user_id, error = parse_positive_int(legacy_user_id, "user_id") + if error or user_id is None: + return None, error or "user_id 非法", True + return DeliveryAddress("qq", user_id), None, True + + return None, None, False + + +def resolve_delivery_address( + args: dict[str, Any], + context: dict[str, Any], +) -> tuple[DeliveryAddress | None, str | None]: + """按显式地址、旧参数、当前地址、当前会话的顺序解析目标。""" + explicit_address, address_error = parse_delivery_address(args.get("address")) + if address_error: + return None, address_error + + legacy_address, legacy_error, has_legacy = _legacy_explicit_address(args, context) + if legacy_error: + return None, legacy_error + if explicit_address is not None: + if has_legacy and legacy_address != explicit_address: + return None, "address 与旧目标参数指向不同会话" + return explicit_address, None + if has_legacy: + return legacy_address, None + + context_address, context_error = parse_delivery_address(context.get("address")) + if context_error: + return None, f"当前会话 {context_error}" + if context_address is not None: + return context_address, None request_type = context.get("request_type") if request_type == "group": - group_id, group_error = parse_positive_int(context.get("group_id"), "group_id") - if group_error: - return None, group_error + group_id, error = parse_positive_int(context.get("group_id"), "group_id") + if error: + return None, error if group_id is not None: - return ("group", group_id), None + return DeliveryAddress("group", group_id), None elif request_type == "private": - user_id, user_error = parse_positive_int(context.get("user_id"), "user_id") - if user_error: - return None, user_error + user_id, error = parse_positive_int(context.get("user_id"), "user_id") + if error: + return None, error if user_id is not None: - return ("private", user_id), None + return DeliveryAddress("qq", user_id), None - fallback_group_id, fallback_group_error = parse_positive_int( + fallback_group_id, group_error = parse_positive_int( context.get("group_id"), "group_id" ) - if fallback_group_error: - return None, fallback_group_error + if group_error: + return None, group_error if fallback_group_id is not None: - return ("group", fallback_group_id), None + return DeliveryAddress("group", fallback_group_id), None - fallback_user_id, fallback_user_error = parse_positive_int( - context.get("user_id"), "user_id" - ) - if fallback_user_error: - return None, fallback_user_error + fallback_user_id, user_error = parse_positive_int(context.get("user_id"), "user_id") + if user_error: + return None, user_error if fallback_user_id is not None: - return ("private", fallback_user_id), None + return DeliveryAddress("qq", fallback_user_id), None + + return None, "无法确定目标会话,请提供 address 或 target_type 与 target_id" + - return None, "无法确定目标会话,请提供 target_type 与 target_id" +def resolve_message_target( + args: dict[str, Any], context: dict[str, Any] +) -> tuple[tuple[TargetType, int] | None, str | None]: + """旧版元组接口;新代码应使用 :func:`resolve_delivery_address`。""" + address, error = resolve_delivery_address(args, context) + if error or address is None: + return None, error + return (address.target_type, address.target_id), None diff --git a/src/Undefined/utils/scheduler.py b/src/Undefined/utils/scheduler.py index 0fc7a76a..a16dce1b 100644 --- a/src/Undefined/utils/scheduler.py +++ b/src/Undefined/utils/scheduler.py @@ -17,7 +17,9 @@ from Undefined.context import RequestContext from Undefined.context_resource_registry import collect_context_resources from Undefined.scheduled_task_storage import ScheduledTaskStorage +from Undefined.utils.message_targets import DeliveryAddress, parse_delivery_address from Undefined.utils.recent_messages import get_recent_messages_prefer_local +from Undefined.utils.sender import AddressBoundSender from Undefined.utils import io logger = logging.getLogger(__name__) @@ -26,6 +28,33 @@ SELF_CALL_TOOL_NAME = "scheduler.call_self" +def _resolve_task_address( + address: object, + target_id: int | None, + target_type: str, +) -> DeliveryAddress | None: + address_text = str(address or "").strip() + if address_text: + resolved, error = parse_delivery_address(address_text) + if error or resolved is None: + raise ValueError(error or "投递地址无效") + return resolved + if target_id is None: + return None + legacy_type = str(target_type or "group").strip().lower() + if legacy_type not in {"group", "private"}: + raise ValueError("target_type 只能是 group 或 private") + channel = "group" if legacy_type == "group" else "qq" + resolved, error = parse_delivery_address(f"{channel}:{target_id}") + if error or resolved is None: + raise ValueError(error or "投递目标无效") + return resolved + + +def _legacy_target_fields(address: DeliveryAddress) -> tuple[int, str]: + return address.target_id, address.target_type + + class TaskScheduler: """任务调度器""" @@ -73,6 +102,16 @@ def _recover_tasks(self) -> None: count = 0 for task_id, info in list(self.tasks.items()): try: + address = _resolve_task_address( + info.get("address"), + info.get("target_id"), + str(info.get("target_type", "group")), + ) + if address is not None: + info["address"] = address.canonical + info["target_id"], info["target_type"] = _legacy_target_fields( + address + ) trigger = CronTrigger.from_crontab(info["cron"]) self.scheduler.add_job( self._execute_tool_wrapper, @@ -110,6 +149,7 @@ async def add_task( tools: list[dict[str, Any]] | None = None, execution_mode: str = "serial", self_instruction: str | None = None, + target_address: str | None = None, ) -> bool: """添加定时任务 @@ -131,6 +171,13 @@ async def add_task( """ try: trigger = CronTrigger.from_crontab(cron_expression) + address = _resolve_task_address( + target_address, + target_id, + target_type, + ) + if address is not None: + target_id, target_type = _legacy_target_fields(address) context_id = await self._save_context_snapshot() @@ -149,6 +196,7 @@ async def add_task( "cron": cron_expression, "target_id": target_id, "target_type": target_type, + "address": address.canonical if address is not None else None, "task_name": task_name or "", "max_executions": max_executions, "current_executions": 0, @@ -207,6 +255,8 @@ async def update_task( tools: list[dict[str, Any]] | None = None, execution_mode: str | None = None, self_instruction: str | None = None, + target_address: str | None = None, + target_address_provided: bool = False, ) -> bool: """修改定时任务(不支持修改 task_id) @@ -257,11 +307,34 @@ async def update_task( if prompt: task_info["self_instruction"] = prompt - if target_id is not None or target_id_provided or target_type is not None: + if target_address_provided: + address = _resolve_task_address( + target_address, + None, + "private", + ) + if address is None: + task_info["address"] = None + task_info["target_id"] = None + else: + task_info["address"] = address.canonical + ( + task_info["target_id"], + task_info["target_type"], + ) = _legacy_target_fields(address) + elif target_id is not None or target_id_provided or target_type is not None: if target_id is not None or target_id_provided: task_info["target_id"] = target_id if target_type is not None: task_info["target_type"] = target_type + address = _resolve_task_address( + None, + task_info.get("target_id"), + str(task_info.get("target_type", "group")), + ) + task_info["address"] = ( + address.canonical if address is not None else None + ) if task_name is not None: task_info["task_name"] = task_name @@ -362,6 +435,8 @@ async def _save_context_snapshot(self) -> str | None: "group_id": ctx.group_id, "user_id": ctx.user_id, "sender_id": ctx.sender_id, + "channel": ctx.get_resource("channel"), + "address": ctx.get_resource("address"), "resource_keys": list(ctx.get_resources().keys()), } await io.write_json(CONTEXT_DIR / f"{context_id}.json", snapshot, use_lock=True) @@ -483,6 +558,11 @@ async def _execute_tool_wrapper( task_info = self.tasks.get(task_id, {}) tools = task_info.get("tools") execution_mode = task_info.get("execution_mode", "serial") + delivery_address = _resolve_task_address( + task_info.get("address"), + target_id, + target_type, + ) # 兼容旧格式:如果没有 tools 字段,使用单工具模式 if not tools: @@ -491,7 +571,10 @@ async def _execute_tool_wrapper( logger.info( f"[任务触发] 定时任务开始执行: ID={task_id}, 工具数={len(tools)}, 模式={execution_mode}" ) - logger.debug(f"[任务详情] 目标={target_id}({target_type})") + logger.debug( + "[任务详情] 目标=%s", + delivery_address.canonical if delivery_address is not None else "未指定", + ) try: context_snapshot = await self._load_context_snapshot( @@ -499,21 +582,36 @@ async def _execute_tool_wrapper( ) if context_snapshot: request_type = context_snapshot.get("request_type") or ( - "group" if target_type == "group" else "private" + delivery_address.target_type + if delivery_address is not None + else ("group" if target_type == "group" else "private") ) group_id = context_snapshot.get("group_id") user_id = context_snapshot.get("user_id") sender_id = context_snapshot.get("sender_id") else: - request_type = "group" if target_type == "group" else "private" + request_type = ( + delivery_address.target_type + if delivery_address is not None + else ("group" if target_type == "group" else "private") + ) group_id = None user_id = None sender_id = None - if request_type == "group" and group_id is None: - group_id = target_id - if request_type == "private" and user_id is None: - user_id = target_id + if delivery_address is not None: + request_type = delivery_address.target_type + if request_type == "group": + group_id = delivery_address.target_id + user_id = None + else: + group_id = None + user_id = delivery_address.target_id + else: + if request_type == "group" and group_id is None: + group_id = target_id + if request_type == "private" and user_id is None: + user_id = target_id async with RequestContext( request_type=request_type, @@ -525,7 +623,16 @@ async def _execute_tool_wrapper( async def send_msg_cb( message: str, reply_to: int | None = None ) -> None: - if request_type == "group" and target_id: + if ( + delivery_address is not None + and delivery_address.channel == "wechat" + ): + await self.sender.send_address_message( + delivery_address, + message, + reply_to=reply_to, + ) + elif request_type == "group" and target_id: await self.sender.send_group_message( target_id, message, reply_to=reply_to ) @@ -537,7 +644,22 @@ async def send_msg_cb( async def send_private_cb( uid: int, msg: str, reply_to: int | None = None ) -> None: - await self.sender.send_private_message(uid, msg, reply_to=reply_to) + if ( + delivery_address is not None + and delivery_address.channel == "wechat" + and delivery_address.target_id == uid + ): + await self.sender.send_address_message( + delivery_address, + msg, + reply_to=reply_to, + ) + else: + await self.sender.send_private_message( + uid, + msg, + reply_to=reply_to, + ) async def send_img_cb(tid: int, mtype: str, path: str) -> None: if not os.path.exists(path): @@ -555,6 +677,17 @@ async def send_img_cb(tid: int, mtype: str, path: str) -> None: await self.sender.send_group_message( tid, msg, auto_history=False ) + elif ( + mtype == "private" + and delivery_address is not None + and delivery_address.channel == "wechat" + and delivery_address.target_id == tid + ): + await self.sender.send_address_message( + delivery_address, + msg, + auto_history=False, + ) elif mtype == "private": await self.sender.send_private_message( tid, msg, auto_history=False @@ -582,7 +715,22 @@ async def send_like_cb(uid: int, times: int = 1) -> None: ai_client = self.ai memory_storage = self.ai.memory_storage runtime_config = self.ai.runtime_config - sender = self.sender + sender = ( + AddressBoundSender(self.sender, delivery_address) + if delivery_address is not None + and delivery_address.channel == "wechat" + else self.sender + ) + channel = ( + delivery_address.channel + if delivery_address is not None + else str((context_snapshot or {}).get("channel") or "") + ) + address = ( + delivery_address.canonical + if delivery_address is not None + else str((context_snapshot or {}).get("address") or "") + ) history_manager = self.history_manager onebot_client = self.onebot scheduler = self @@ -607,6 +755,11 @@ async def send_like_cb(uid: int, times: int = 1) -> None: for key, value in resources.items(): if value is not None: ctx.set_resource(key, value) + if channel: + ctx.set_resource("channel", channel) + if address: + ctx.set_resource("address", address) + ctx.set_resource("sender", sender) start_time = time.perf_counter() results = [] diff --git a/src/Undefined/utils/sender.py b/src/Undefined/utils/sender.py index 39d98315..0a909b51 100644 --- a/src/Undefined/utils/sender.py +++ b/src/Undefined/utils/sender.py @@ -15,10 +15,12 @@ process_at_mentions, ) from Undefined.utils.logging import redact_string +from Undefined.utils.message_targets import DeliveryAddress +from Undefined.utils.message_turn import mark_message_sent_this_turn logger = logging.getLogger(__name__) -# QQ 消息长度限制(保守估算) +# OneBot 与微信 iLink 的单条文本长度限制(保守值) MAX_MESSAGE_LENGTH = 4000 @@ -130,6 +132,23 @@ def _get_file_size(file_path: str) -> int | None: return None +def _split_text_chunks(text: str, limit: int = MAX_MESSAGE_LENGTH) -> list[str]: + """按长度分片,优先在换行后切分并保留原始文本。""" + if not text: + return [] + chunks: list[str] = [] + start = 0 + while start < len(text): + end = min(start + limit, len(text)) + if end < len(text): + newline = text.rfind("\n", start, end) + if newline >= start: + end = newline + 1 + chunks.append(text[start:end]) + start = end + return chunks + + class MessageSender: """消息发送器""" @@ -146,6 +165,206 @@ def __init__( self.bot_qq = bot_qq self.config = config self.attachment_registry = attachment_registry + self._weixin_service: Any | None = None + + def set_weixin_service(self, service: Any | None) -> None: + """注入可选的微信投递服务。""" + self._weixin_service = service + + async def send_address_message( + self, + address: DeliveryAddress, + message: str, + auto_history: bool = True, + *, + mark_sent: bool = True, + reply_to: int | None = None, + preferred_temp_group_id: int | None = None, + history_message: str | None = None, + attachments: list[dict[str, str]] | None = None, + ) -> int | None: + """按规范地址投递消息,并保留原 QQ 方法的兼容语义。""" + if address.channel == "group": + return await self.send_group_message( + address.target_id, + message, + auto_history=auto_history, + mark_sent=mark_sent, + reply_to=reply_to, + history_message=history_message, + attachments=attachments, + ) + if address.channel == "qq": + return await self.send_private_message( + address.target_id, + message, + auto_history=auto_history, + mark_sent=mark_sent, + reply_to=reply_to, + preferred_temp_group_id=preferred_temp_group_id, + history_message=history_message, + attachments=attachments, + ) + return await self._send_weixin_message( + address.target_id, + message, + auto_history=auto_history, + mark_sent=mark_sent, + reply_to=reply_to, + history_message=history_message, + attachments=attachments, + ) + + async def send_address_file( + self, + address: DeliveryAddress, + file_path: str, + name: str | None = None, + *, + kind: str | None = None, + auto_history: bool = True, + ) -> None: + """按规范地址发送一个本地文件。""" + if address.channel == "group": + await self.send_group_file( + address.target_id, file_path, name=name, auto_history=auto_history + ) + return + if address.channel == "qq": + await self.send_private_file( + address.target_id, file_path, name=name, auto_history=auto_history + ) + return + if not self.config.is_private_allowed(address.target_id): + raise PermissionError( + f"blocked by access control: type=private user_id={address.target_id}" + ) + service = self._require_weixin_service() + await service.send_file( + address.target_id, + file_path, + name=name, + kind=kind, + ) + if not auto_history: + return + file_name = name or Path(file_path).name + file_size = _get_file_size(file_path) + history_attachments = await self.register_sent_file_attachment( + "private", + address.target_id, + file_path, + file_name, + kind=kind or "file", + ) + history_content = _append_attachment_refs( + _build_file_history_message(file_name, file_size), + history_attachments, + ) + await self.history_manager.add_private_message( + user_id=address.target_id, + text_content=history_content, + display_name="Bot", + user_name="Bot", + attachments=history_attachments, + transport={ + "channel": "wechat", + "address": address.canonical, + }, + ) + + async def set_address_typing(self, address: DeliveryAddress, typing: bool) -> None: + """仅微信支持的输入状态提示;其他通道无操作。""" + if address.channel != "wechat": + return + service = self._require_weixin_service() + await service.set_typing(address.target_id, typing) + + def _require_weixin_service(self) -> Any: + if self._weixin_service is None: + raise RuntimeError("微信消息服务未启用") + return self._weixin_service + + async def _send_weixin_message( + self, + user_id: int, + message: str, + *, + auto_history: bool, + mark_sent: bool, + reply_to: int | None, + history_message: str | None, + attachments: list[dict[str, str]] | None, + ) -> int | None: + if not self.config.is_private_allowed(user_id): + raise PermissionError( + f"blocked by access control: type=private user_id={user_id}" + ) + if reply_to is not None: + raise ValueError("微信 iLink 暂不支持引用回复(reply_to)") + service = self._require_weixin_service() + segments = message_to_segments(message) + text = extract_text(segments, self.bot_qq).strip() + media_segments: list[tuple[str, Path]] = [] + for segment in segments: + segment_type = str(segment.get("type", "") or "").strip().lower() + if segment_type not in {"image", "video", "file", "record"}: + continue + if segment_type == "record": + raise ValueError("微信 iLink 暂不支持发送语音") + data = segment.get("data") + if not isinstance(data, dict): + raise ValueError("微信媒体消息缺少有效参数") + path = _local_path_from_segment_source(data.get("file")) + if path is None: + raise ValueError("微信 iLink 当前只支持发送本地媒体文件") + media_segments.append((segment_type, path)) + + sent_any = False + for chunk in _split_text_chunks(text): + await service.send_text(user_id, chunk) + sent_any = True + if mark_sent: + mark_message_sent_this_turn() + + history_attachments = _merge_attachment_refs( + attachments, + await self._register_local_segment_attachments( + "private", + user_id, + segments, + ), + ) + for segment_type, path in media_segments: + await service.send_file( + user_id, + path, + name=path.name, + kind=("image" if segment_type == "image" else segment_type), + ) + sent_any = True + if mark_sent: + mark_message_sent_this_turn() + if not sent_any and message.strip(): + raise ValueError("微信消息中没有可投递的文本或本地媒体") + + if auto_history: + history_content = history_message + if history_content is None: + history_content = text + history_content = _append_attachment_refs( + history_content, + history_attachments, + ) + await self.history_manager.add_private_message( + user_id=user_id, + text_content=history_content, + display_name="Bot", + user_name="Bot", + attachments=history_attachments, + transport={"channel": "wechat", "address": f"wechat:{user_id}"}, + ) + return None async def register_sent_file_attachment( self, @@ -908,3 +1127,146 @@ async def send_private_file( user_id, file_name, ) + + +class AddressBoundSender: + """将当前逻辑私聊绑定到指定物理地址的轻量发送代理。""" + + def __init__(self, sender: MessageSender, address: DeliveryAddress) -> None: + if address.target_type != "private": + raise ValueError("AddressBoundSender 仅支持私聊地址") + self._sender = sender + self._address = address + + def __getattr__(self, name: str) -> Any: + return getattr(self._sender, name) + + async def send_private_message( + self, + user_id: int, + message: str, + auto_history: bool = True, + *, + mark_sent: bool = True, + reply_to: int | None = None, + preferred_temp_group_id: int | None = None, + history_message: str | None = None, + attachments: list[dict[str, str]] | None = None, + ) -> int | None: + if int(user_id) != self._address.target_id: + return await self._sender.send_private_message( + user_id, + message, + auto_history=auto_history, + mark_sent=mark_sent, + reply_to=reply_to, + preferred_temp_group_id=preferred_temp_group_id, + history_message=history_message, + attachments=attachments, + ) + return await self._sender.send_address_message( + self._address, + message, + auto_history=auto_history, + mark_sent=mark_sent, + reply_to=reply_to, + preferred_temp_group_id=preferred_temp_group_id, + history_message=history_message, + attachments=attachments, + ) + + async def send_private_file( + self, + user_id: int, + file_path: str, + name: str | None = None, + auto_history: bool = True, + ) -> None: + if int(user_id) != self._address.target_id: + await self._sender.send_private_file( + user_id, + file_path, + name=name, + auto_history=auto_history, + ) + return + await self._sender.send_address_file( + self._address, + file_path, + name=name, + auto_history=auto_history, + ) + + async def send_private_forward_message( + self, + user_id: int, + messages: list[dict[str, Any]], + *, + history_message: str, + auto_history: bool = True, + ) -> None: + if int(user_id) != self._address.target_id or self._address.channel == "qq": + await self._sender.send_private_forward_message( + user_id, + messages, + history_message=history_message, + auto_history=auto_history, + ) + return + await self._send_weixin_forward(messages) + if auto_history: + await self._sender.history_manager.add_private_message( + user_id=user_id, + text_content=history_message, + display_name="Bot", + user_name="Bot", + transport={ + "channel": "wechat", + "address": self._address.canonical, + }, + ) + + async def _send_weixin_forward(self, messages: list[dict[str, Any]]) -> None: + for node in messages: + data = node.get("data") + if not isinstance(data, dict): + continue + content = data.get("content") + if isinstance(content, str) and content.strip(): + await self._sender.send_address_message( + self._address, + content, + auto_history=False, + ) + continue + if not isinstance(content, list): + continue + text_parts: list[str] = [] + for segment in content: + if not isinstance(segment, dict): + continue + segment_type = str(segment.get("type", "") or "").strip().lower() + segment_data = segment.get("data") + if not isinstance(segment_data, dict): + continue + if segment_type == "text": + text_parts.append(str(segment_data.get("text", "") or "")) + continue + if segment_type not in {"image", "video", "file"}: + continue + path = _local_path_from_segment_source(segment_data.get("file")) + if path is not None: + await self._sender.send_address_file( + self._address, + str(path), + name=path.name, + kind=segment_type, + auto_history=False, + ) + text = "".join(text_parts).strip() + if text: + await self._sender.send_address_message( + self._address, + text, + auto_history=False, + ) diff --git a/src/Undefined/utils/xml.py b/src/Undefined/utils/xml.py index 05a4e618..8093c7ac 100644 --- a/src/Undefined/utils/xml.py +++ b/src/Undefined/utils/xml.py @@ -63,10 +63,16 @@ def escape_xml_text_preserving_attachment_tags( return "".join(parts) -def _message_location(msg_type: str, chat_name: str) -> str: +def _message_location( + msg_type: str, + chat_name: str, + transport: Mapping[str, Any] | None = None, +) -> str: """Derive the human-readable location label from message type.""" if msg_type == "group": return chat_name if chat_name.endswith("群") else f"{chat_name}群" + if transport and str(transport.get("channel", "")).strip() == "wechat": + return "微信私聊" return "私聊" @@ -94,6 +100,8 @@ def format_message_xml( title = str(msg.get("title", "") or "") level = str(msg.get("level", "") or "") attachments = msg.get("attachments", []) + transport_raw = msg.get("transport") + transport = transport_raw if isinstance(transport_raw, Mapping) else None safe_sender = escape_xml_attr(sender_name) safe_sender_id = escape_xml_attr(sender_id) @@ -103,7 +111,9 @@ def format_message_xml( safe_title = escape_xml_attr(title) safe_time = escape_xml_attr(timestamp) safe_text = escape_xml_text_preserving_attachment_tags(text, attachments) - safe_location = escape_xml_attr(_message_location(msg_type_val, chat_name)) + safe_location = escape_xml_attr( + _message_location(msg_type_val, chat_name, transport) + ) msg_id_attr = "" if message_id is not None: @@ -127,9 +137,17 @@ def format_message_xml( f"" ) + transport_attrs = "" + if transport: + channel = str(transport.get("channel", "") or "").strip() + address = str(transport.get("address", "") or "").strip() + if channel: + transport_attrs += f' channel="{escape_xml_attr(channel)}"' + if address: + transport_attrs += f' address="{escape_xml_attr(address)}"' return ( f'\n' + f'{transport_attrs.lstrip()} location="{safe_location}" time="{safe_time}">\n' f"{safe_text}{attachment_xml}\n" f"" ) diff --git a/src/Undefined/webui/routes/__init__.py b/src/Undefined/webui/routes/__init__.py index f1df9b1b..6c9923ff 100644 --- a/src/Undefined/webui/routes/__init__.py +++ b/src/Undefined/webui/routes/__init__.py @@ -1,4 +1,14 @@ from ._shared import routes __all__ = ["routes"] -from . import _index, _auth, _bot, _config, _logs, _system, _runtime, _memes # noqa: F401 register handlers +from . import ( # noqa: F401 register handlers + _auth, + _bot, + _config, + _index, + _logs, + _memes, + _runtime, + _system, + _weixin, +) diff --git a/src/Undefined/webui/routes/_weixin.py b/src/Undefined/webui/routes/_weixin.py new file mode 100644 index 00000000..9f1830b2 --- /dev/null +++ b/src/Undefined/webui/routes/_weixin.py @@ -0,0 +1,165 @@ +"""Management WebUI 到 Runtime 微信管理 API 的鉴权代理。""" + +from __future__ import annotations + +import json +from urllib.parse import quote + +from aiohttp import web +from aiohttp.web_response import Response + +from ._runtime import _proxy_runtime, _proxy_runtime_binary +from ._shared import check_auth, routes + + +def _unauthorized() -> Response: + return web.json_response({"error": "Unauthorized"}, status=401) + + +async def _body(request: web.Request) -> dict[str, object] | None: + try: + value = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + return None + return value if isinstance(value, dict) else None + + +@routes.get("/api/v1/management/runtime/weixin") +async def management_weixin_status(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime(method="GET", path="/api/v1/weixin") + + +@routes.post("/api/v1/management/runtime/weixin/login") +async def management_weixin_login_start(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + payload = await _body(request) + if payload is None: + return web.json_response({"error": "Invalid JSON payload"}, status=400) + return await _proxy_runtime( + method="POST", + path="/api/v1/weixin/login", + payload=payload, + timeout_seconds=30.0, + ) + + +@routes.get("/api/v1/management/runtime/weixin/login/{session_id}/qr.png") +async def management_weixin_login_qr(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + session_id = quote(str(request.match_info.get("session_id", "")).strip(), safe="") + response = await _proxy_runtime_binary( + method="GET", + path=f"/api/v1/weixin/login/{session_id}/qr.png", + ) + response.headers["Cache-Control"] = "no-store, max-age=0" + return response + + +@routes.post("/api/v1/management/runtime/weixin/login/{session_id}/refresh") +async def management_weixin_login_refresh(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + session_id = quote(str(request.match_info.get("session_id", "")).strip(), safe="") + return await _proxy_runtime( + method="POST", + path=f"/api/v1/weixin/login/{session_id}/refresh", + payload={}, + timeout_seconds=30.0, + ) + + +@routes.post("/api/v1/management/runtime/weixin/login/{session_id}/verify") +async def management_weixin_login_verify(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + payload = await _body(request) + if payload is None: + return web.json_response({"error": "Invalid JSON payload"}, status=400) + session_id = quote(str(request.match_info.get("session_id", "")).strip(), safe="") + return await _proxy_runtime( + method="POST", + path=f"/api/v1/weixin/login/{session_id}/verify", + payload=payload, + ) + + +@routes.get("/api/v1/management/runtime/weixin/login/{session_id}") +async def management_weixin_login_poll(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + session_id = quote(str(request.match_info.get("session_id", "")).strip(), safe="") + return await _proxy_runtime( + method="GET", + path=f"/api/v1/weixin/login/{session_id}", + timeout_seconds=30.0, + ) + + +@routes.delete("/api/v1/management/runtime/weixin/login/{session_id}") +async def management_weixin_login_cancel(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + session_id = quote(str(request.match_info.get("session_id", "")).strip(), safe="") + return await _proxy_runtime( + method="DELETE", + path=f"/api/v1/weixin/login/{session_id}", + ) + + +@routes.patch("/api/v1/management/runtime/weixin/accounts/{alias}") +async def management_weixin_account_update(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + payload = await _body(request) + if payload is None: + return web.json_response({"error": "Invalid JSON payload"}, status=400) + alias = quote(str(request.match_info.get("alias", "")).strip(), safe="") + return await _proxy_runtime( + method="PATCH", + path=f"/api/v1/weixin/accounts/{alias}", + payload=payload, + ) + + +@routes.delete("/api/v1/management/runtime/weixin/accounts/{alias}") +async def management_weixin_account_delete(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + alias = quote(str(request.match_info.get("alias", "")).strip(), safe="") + return await _proxy_runtime( + method="DELETE", + path=f"/api/v1/weixin/accounts/{alias}", + ) + + +@routes.get("/api/v1/management/runtime/weixin/pending") +async def management_weixin_pending_list(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime(method="GET", path="/api/v1/weixin/pending") + + +@routes.delete("/api/v1/management/runtime/weixin/pending/{record_id}") +async def management_weixin_pending_delete(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + record_id = quote(str(request.match_info.get("record_id", "")).strip(), safe="") + return await _proxy_runtime( + method="DELETE", + path=f"/api/v1/weixin/pending/{record_id}", + ) + + +@routes.get("/api/v1/management/runtime/weixin/audit") +async def management_weixin_audit_list(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/weixin/audit", + params=request.query, + ) diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css index af82520f..e7fdca9b 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -507,6 +507,341 @@ body.update-dialog-open { overflow: hidden; } } .status-msg.success { color: var(--success); } .status-msg.error { color: var(--error); } + +/* WeChat iLink management */ +#tab-weixin { + --weixin-accent: #1f8f55; + --weixin-accent-hover: #197646; + --weixin-accent-soft: rgba(31, 143, 85, 0.1); +} +#tab-weixin .btn.primary, +.weixin-dialog .btn.primary { + background: var(--weixin-accent, #1f8f55); +} +#tab-weixin .btn.primary:hover, +.weixin-dialog .btn.primary:hover { + background: var(--weixin-accent-hover, #197646); +} +.weixin-notice { + border-radius: var(--radius-sm); +} +.weixin-status-band { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 28px; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); +} +.weixin-status-item { + display: grid; + gap: 7px; + min-width: 0; + padding: 18px 20px; + border-right: 1px solid var(--border-color); +} +.weixin-status-item:last-child { + border-right: 0; +} +.weixin-status-item span { + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} +.weixin-status-item strong { + overflow: hidden; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} +.weixin-status-item strong.is-online { + color: var(--weixin-accent); +} +.weixin-section { + min-width: 0; + padding: 24px 0 30px; + border-top: 1px solid var(--border-color); +} +.weixin-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} +.weixin-section-heading h2 { + margin: 0 0 5px; + color: var(--text-primary); + font-size: 16px; +} +.weixin-section-heading p { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} +.weixin-table-wrap { + overflow-x: auto; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-card); +} +.weixin-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + table-layout: fixed; +} +.weixin-table th, +.weixin-table td { + padding: 13px 15px; + border-bottom: 1px solid var(--border-color); + text-align: left; + vertical-align: middle; +} +.weixin-table th { + background: var(--bg-app); + color: var(--text-tertiary); + font-size: 11px; + font-weight: 650; + text-transform: uppercase; +} +.weixin-table td { + color: var(--text-secondary); + font-size: 13px; +} +.weixin-table tr:last-child td { + border-bottom: 0; +} +.weixin-table code { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; +} +.weixin-table .weixin-actions-col { + width: 260px; + text-align: right; +} +.weixin-table-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 7px; +} +.weixin-table .empty-state { + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; +} +.weixin-state { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--text-secondary); + font-size: 12px; +} +.weixin-state::before { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-tertiary); + content: ""; +} +.weixin-state.is-connected { + color: var(--weixin-accent); +} +.weixin-state.is-connected::before { + background: var(--weixin-accent); + box-shadow: 0 0 0 3px var(--weixin-accent-soft); +} +.weixin-state.is-error, +.weixin-account-error { + color: var(--error); +} +.weixin-state.is-error::before { + background: var(--error); +} +.weixin-account-error { + display: block; + max-width: 28ch; + margin-top: 4px; + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.weixin-secondary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 32px; +} +.weixin-list { + display: grid; + min-height: 76px; +} +.weixin-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px 14px; + padding: 13px 0; + border-bottom: 1px solid var(--border-color); +} +.weixin-list-item:first-child { + border-top: 1px solid var(--border-color); +} +.weixin-list-main { + min-width: 0; +} +.weixin-list-title { + display: block; + overflow: hidden; + color: var(--text-primary); + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} +.weixin-list-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 5px; + color: var(--text-tertiary); + font-size: 11px; + line-height: 1.5; +} +.weixin-list-empty { + padding: 18px 0; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + color: var(--text-tertiary); + font-size: 12px; +} +.weixin-audit-detail { + max-width: 100%; + overflow: hidden; + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} +.weixin-dialog-backdrop { + position: fixed; + z-index: 2100; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 17, 18, 0.58); + backdrop-filter: blur(5px); +} +.weixin-dialog-backdrop[hidden] { + display: none; +} +.weixin-dialog { + width: min(520px, 100%); + max-height: min(760px, calc(100dvh - 40px)); + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-card); + box-shadow: var(--shadow-lg); +} +.weixin-dialog-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 22px 24px 18px; + border-bottom: 1px solid var(--border-color); +} +.weixin-dialog-head h2 { + margin: 3px 0 0; + color: var(--text-primary); + font-size: 19px; +} +.weixin-dialog-kicker { + color: var(--weixin-accent, #1f8f55); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} +.weixin-binding-form, +.weixin-qr-step { + padding: 22px 24px 24px; +} +.weixin-binding-form .form-group:last-of-type { + margin-bottom: 12px; +} +.weixin-confirmation { + margin: 4px 0 18px; + padding: 12px 14px; + border-left: 3px solid var(--warning); + background: rgba(204, 137, 37, 0.08); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} +.weixin-confirmation strong { + color: var(--warning); +} +.weixin-confirmation p { + margin: 6px 0 0; +} +.weixin-dialog-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 9px; + padding-top: 18px; + border-top: 1px solid var(--border-color); +} +.weixin-qr-step { + display: grid; + justify-items: center; + gap: 12px; + text-align: center; +} +.weixin-qr-frame { + width: min(280px, 80vw); + aspect-ratio: 1; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: #fff; +} +.weixin-qr-frame img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.weixin-qr-step > strong { + color: var(--text-primary); + font-size: 15px; +} +.weixin-qr-step .muted-sm { + min-height: 18px; + margin: 0; + color: var(--text-secondary); + font-size: 12px; +} +.weixin-verify-form { + width: 100%; + padding-top: 8px; + text-align: left; +} +.weixin-verify-form .runtime-inline { + flex-wrap: nowrap; +} +.weixin-verify-form .form-control { + min-width: 0; +} +.weixin-qr-step .weixin-dialog-actions { + width: 100%; +} .runtime-query-stack { display: grid; gap: 8px; diff --git a/src/Undefined/webui/static/css/responsive.css b/src/Undefined/webui/static/css/responsive.css index 5ecf1fab..ce5489a7 100644 --- a/src/Undefined/webui/static/css/responsive.css +++ b/src/Undefined/webui/static/css/responsive.css @@ -12,6 +12,7 @@ padding-right: 30px; } .search-group .form-control { min-width: 0; } + .weixin-secondary-grid { grid-template-columns: minmax(0, 1fr); gap: 0; } .main-content.chat-layout { height: 100dvh; padding-bottom: max(14px, env(safe-area-inset-bottom)); @@ -19,6 +20,12 @@ } @media (max-width: 768px) { + html, + body { + max-width: 100%; + overflow-x: hidden; + } + .app-container { grid-template-columns: 1fr; height: auto; @@ -68,6 +75,76 @@ .main-content.chat-layout #tab-chat .runtime-chat-header { gap: 10px; } + .weixin-status-band { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .weixin-status-item:nth-child(2) { border-right: 0; } + .weixin-status-item:nth-child(-n + 2) { border-bottom: 1px solid var(--border-color); } + .weixin-table-wrap { + overflow: visible; + border: 0; + background: transparent; + } + .weixin-table { + min-width: 0; + table-layout: auto; + } + .weixin-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + } + .weixin-table tbody { + display: grid; + gap: 12px; + } + .weixin-table tr { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 16px; + padding: 14px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-card); + } + .weixin-table td, + .weixin-table tr:last-child td { + display: grid; + gap: 4px; + min-width: 0; + padding: 0; + border: 0; + } + .weixin-table td::before { + color: var(--text-tertiary); + content: attr(data-label); + font-size: 10px; + font-weight: 650; + text-transform: uppercase; + } + .weixin-table code { + overflow-wrap: anywhere; + white-space: normal; + } + .weixin-table .weixin-actions-col { + grid-column: 1 / -1; + width: auto; + text-align: left; + } + .weixin-table-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + .weixin-dialog-backdrop { align-items: end; padding: 0; } + .weixin-dialog { + width: 100%; + max-height: calc(100dvh - env(safe-area-inset-top)); + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + } .runtime-chat-shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); @@ -665,6 +742,17 @@ display: grid; grid-template-columns: 1fr; } + #tab-weixin .toolbar { display: grid; grid-template-columns: 1fr 1fr; } + #tab-weixin .toolbar .btn { width: 100%; padding-inline: 12px; } + .weixin-status-item { padding: 14px 12px; } + .weixin-status-item strong { font-size: 17px; } + .weixin-dialog-head, + .weixin-binding-form, + .weixin-qr-step { padding-right: 18px; padding-left: 18px; } + .weixin-dialog-actions { align-items: stretch; flex-direction: column-reverse; } + .weixin-dialog-actions .btn { width: 100%; } + .weixin-list-item { grid-template-columns: minmax(0, 1fr); } + .weixin-list-item > .btn { justify-self: start; } .log-viewer { height: 52vh; } .probe-item { grid-template-columns: 1fr; gap: 6px; } .probe-item-value { justify-self: flex-start; } diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js index eca89b7a..a9a68071 100644 --- a/src/Undefined/webui/static/js/i18n.js +++ b/src/Undefined/webui/static/js/i18n.js @@ -29,6 +29,7 @@ const I18N = { "tabs.probes": "探针诊断", "tabs.memory": "记忆检索", "tabs.memes": "表情包库", + "tabs.weixin": "微信接入", "tabs.schedules": "定时任务", "tabs.runtime": "运行接口", "tabs.chat": "智能对话", @@ -261,6 +262,9 @@ const I18N = { "schedules.target_group": "群聊", "schedules.target_private": "私聊", "schedules.target_id": "目标 ID", + "schedules.target_address": "投递地址", + "schedules.target_address_hint": + "支持 qq:、group:<群号> 或 wechat:<逻辑QQ号>。", "schedules.mode_single": "单工具", "schedules.mode_multi": "多工具", "schedules.mode_self": "自我督办", @@ -289,6 +293,82 @@ const I18N = { "schedules.save_failed": "保存失败", "schedules.confirm_delete": "确定删除这个定时任务吗?", "schedules.deleted": "定时任务已删除", + "weixin.title": "微信接入", + "weixin.subtitle": "管理 iLink 帐号与逻辑 QQ 身份绑定。", + "weixin.refresh": "刷新", + "weixin.bind": "绑定帐号", + "weixin.disabled": + "微信接入未启用。请先设置 weixin.enabled = true 并重启 Bot。", + "weixin.runtime": "运行状态", + "weixin.accounts": "绑定帐号", + "weixin.connected": "在线帐号", + "weixin.quarantined": "隔离来源", + "weixin.account_list": "帐号绑定", + "weixin.account_scope": "每个逻辑 QQ 号最多绑定一个微信帐号。", + "weixin.alias": "别名", + "weixin.identity": "逻辑身份", + "weixin.identity_qq": "逻辑 QQ 号", + "weixin.state": "状态", + "weixin.updated": "更新时间", + "weixin.actions": "操作", + "weixin.empty": "暂无微信帐号绑定。", + "weixin.pending_title": "隔离来源", + "weixin.pending_scope": "未匹配绑定身份的消息不会进入历史或 AI。", + "weixin.pending_empty": "暂无隔离来源。", + "weixin.audit_title": "绑定审计", + "weixin.audit_scope": "最近的登录、启停、改绑与解绑操作。", + "weixin.audit_empty": "暂无审计记录。", + "weixin.dialog_kicker": "微信 iLink", + "weixin.dialog_bind": "绑定帐号", + "weixin.dialog_rebind": "改绑逻辑身份", + "weixin.confirm_title": "高权限身份确认", + "weixin.continue": "继续", + "weixin.confirm_continue": "确认并继续", + "weixin.save_rebind": "保存改绑", + "weixin.wait_scan": "等待微信扫码", + "weixin.verify_code": "验证码", + "weixin.submit": "提交", + "weixin.cancel_login": "取消登录", + "weixin.refresh_qr": "刷新二维码", + "weixin.runtime_disabled": "未启用", + "weixin.runtime_running": "运行中", + "weixin.runtime_stopped": "未启动", + "weixin.state_connected": "在线", + "weixin.state_error": "异常", + "weixin.state_disabled": "已停用", + "weixin.state_offline": "离线", + "weixin.disable_account": "停用帐号", + "weixin.enable_account": "启用帐号", + "weixin.rebind": "改绑", + "weixin.unbind": "解绑", + "weixin.unexpected_peer": "未知来源", + "weixin.count": "次数", + "weixin.dismiss": "忽略", + "weixin.invalid_binding": "请填写有效的帐号别名和正整数 QQ 号。", + "weixin.rebind_saved": "逻辑身份已更新。", + "weixin.login_confirmed": "绑定完成", + "weixin.need_verify": "需要验证码", + "weixin.scanned": "已扫码,等待确认", + "weixin.qr_expired": "二维码已过期", + "weixin.verify_blocked": "验证码尝试已受限", + "weixin.already_bound": "该微信帐号已绑定其他 Bot", + "weixin.poll_failed": "查询登录状态失败", + "weixin.refresh_failed": "刷新二维码失败", + "weixin.verify_submitted": "验证码已提交", + "weixin.verify_failed": "验证码提交失败", + "weixin.account_enabled": "微信帐号已启用", + "weixin.account_disabled": "微信帐号已停用", + "weixin.confirm_unbind": + "确定解绑这个微信帐号吗?本地登录凭据也会被删除。", + "weixin.unbound": "微信帐号已解绑", + "weixin.audit_action_login_started": "开始扫码登录", + "weixin.audit_action_account_bound": "帐号已绑定", + "weixin.audit_action_account_enabled": "帐号已启用", + "weixin.audit_action_account_disabled": "帐号已停用", + "weixin.audit_action_account_removed": "帐号已解绑", + "weixin.audit_action_account_rebound": "逻辑身份已改绑", + "weixin.audit_action_privileged_binding_confirmed": + "高权限身份绑定已确认", "runtime.title": "Runtime 能力中心", "runtime.subtitle": "探针、记忆查询与侧写查看。", "runtime.refresh_all": "刷新全部", @@ -496,6 +576,7 @@ const I18N = { "cmd.tab_memory": "跳转到 备忘录", "cmd.tab_memes": "跳转到 表情包", "cmd.tab_schedules": "跳转到 定时任务", + "cmd.tab_weixin": "跳转到 微信接入", "cmd.tab_cognitive": "跳转到 认知记忆", "cmd.refresh": "刷新当前页面", "cmd.logout": "退出登录", @@ -531,6 +612,7 @@ const I18N = { "tabs.probes": "Probe Hub", "tabs.memory": "Memory Hub", "tabs.memes": "Memes", + "tabs.weixin": "WeChat", "tabs.schedules": "Schedules", "tabs.runtime": "Runtime API", "tabs.chat": "AI Dialog", @@ -770,6 +852,9 @@ const I18N = { "schedules.target_group": "Group", "schedules.target_private": "Private", "schedules.target_id": "Target ID", + "schedules.target_address": "Delivery address", + "schedules.target_address_hint": + "Use qq:, group:, or wechat:.", "schedules.mode_single": "Single Tool", "schedules.mode_multi": "Multi-tool", "schedules.mode_self": "Self Call", @@ -798,6 +883,87 @@ const I18N = { "schedules.save_failed": "Save failed", "schedules.confirm_delete": "Delete this scheduled task?", "schedules.deleted": "Schedule deleted", + "weixin.title": "WeChat Integration", + "weixin.subtitle": + "Manage iLink accounts and their logical QQ identities.", + "weixin.refresh": "Refresh", + "weixin.bind": "Bind account", + "weixin.disabled": + "WeChat integration is disabled. Set weixin.enabled = true and restart the bot.", + "weixin.runtime": "Runtime", + "weixin.accounts": "Accounts", + "weixin.connected": "Connected", + "weixin.quarantined": "Quarantined", + "weixin.account_list": "Account bindings", + "weixin.account_scope": + "Each logical QQ identity can have at most one WeChat account.", + "weixin.alias": "Alias", + "weixin.identity": "Logical identity", + "weixin.identity_qq": "Logical QQ ID", + "weixin.state": "State", + "weixin.updated": "Updated", + "weixin.actions": "Actions", + "weixin.empty": "No WeChat accounts are bound.", + "weixin.pending_title": "Quarantined sources", + "weixin.pending_scope": + "Messages from unmatched sources never enter history or AI.", + "weixin.pending_empty": "No quarantined sources.", + "weixin.audit_title": "Binding audit", + "weixin.audit_scope": + "Recent login, enable, disable, rebind, and unbind operations.", + "weixin.audit_empty": "No audit records.", + "weixin.dialog_kicker": "WeChat iLink", + "weixin.dialog_bind": "Bind account", + "weixin.dialog_rebind": "Rebind logical identity", + "weixin.confirm_title": "Privileged identity confirmation", + "weixin.continue": "Continue", + "weixin.confirm_continue": "Confirm and continue", + "weixin.save_rebind": "Save rebind", + "weixin.wait_scan": "Waiting for WeChat scan", + "weixin.verify_code": "Verification code", + "weixin.submit": "Submit", + "weixin.cancel_login": "Cancel login", + "weixin.refresh_qr": "Refresh QR code", + "weixin.runtime_disabled": "Disabled", + "weixin.runtime_running": "Running", + "weixin.runtime_stopped": "Stopped", + "weixin.state_connected": "Connected", + "weixin.state_error": "Error", + "weixin.state_disabled": "Disabled", + "weixin.state_offline": "Offline", + "weixin.disable_account": "Disable account", + "weixin.enable_account": "Enable account", + "weixin.rebind": "Rebind", + "weixin.unbind": "Unbind", + "weixin.unexpected_peer": "Unknown source", + "weixin.count": "Count", + "weixin.dismiss": "Dismiss", + "weixin.invalid_binding": + "Enter a valid account alias and a positive QQ ID.", + "weixin.rebind_saved": "Logical identity updated.", + "weixin.login_confirmed": "Binding complete", + "weixin.need_verify": "Verification code required", + "weixin.scanned": "Scanned, awaiting confirmation", + "weixin.qr_expired": "QR code expired", + "weixin.verify_blocked": "Verification attempts are restricted", + "weixin.already_bound": "This WeChat account is bound to another bot", + "weixin.poll_failed": "Failed to query login status", + "weixin.refresh_failed": "Failed to refresh QR code", + "weixin.verify_submitted": "Verification code submitted", + "weixin.verify_failed": "Failed to submit verification code", + "weixin.account_enabled": "WeChat account enabled", + "weixin.account_disabled": "WeChat account disabled", + "weixin.confirm_unbind": + "Unbind this WeChat account? Its local credentials will also be deleted.", + "weixin.unbound": "WeChat account unbound", + "weixin.audit_action_login_started": "QR login started", + "weixin.audit_action_account_bound": "Account bound", + "weixin.audit_action_account_enabled": "Account enabled", + "weixin.audit_action_account_disabled": "Account disabled", + "weixin.audit_action_account_removed": "Account unbound", + "weixin.audit_action_account_rebound": "Logical identity rebound", + "weixin.audit_action_privileged_binding_confirmed": + "Privileged identity binding confirmed", "runtime.title": "Runtime Hub", "runtime.subtitle": "Probes plus memory/profile read-only queries.", "runtime.refresh_all": "Refresh All", @@ -1020,6 +1186,7 @@ const I18N = { "cmd.tab_memory": "Go to Memory", "cmd.tab_memes": "Go to Memes", "cmd.tab_schedules": "Go to Schedules", + "cmd.tab_weixin": "Go to WeChat", "cmd.tab_cognitive": "Go to Cognitive", "cmd.refresh": "Refresh current page", "cmd.logout": "Logout", diff --git a/src/Undefined/webui/static/js/main.js b/src/Undefined/webui/static/js/main.js index b4725b47..09fd5668 100644 --- a/src/Undefined/webui/static/js/main.js +++ b/src/Undefined/webui/static/js/main.js @@ -154,6 +154,12 @@ function refreshUI() { ) { window.SchedulesController.onTabActivated(state.tab); } + if ( + window.WeixinController && + typeof window.WeixinController.onTabActivated === "function" + ) { + window.WeixinController.onTabActivated(state.tab); + } } else { get("appContent").style.display = "none"; state.configLoaded = false; @@ -239,6 +245,12 @@ function switchTab(tab) { ) { window.SchedulesController.onTabActivated(tab); } + if ( + window.WeixinController && + typeof window.WeixinController.onTabActivated === "function" + ) { + window.WeixinController.onTabActivated(tab); + } if (tab === "about") { maybeLoadAboutChangelog(); } @@ -362,11 +374,17 @@ const _cmdCommands = [ action: () => switchTab("schedules"), keys: "7", }, + { + id: "weixin", + label: () => t("cmd.tab_weixin"), + action: () => switchTab("weixin"), + keys: "8", + }, { id: "cognitive", label: () => t("cmd.tab_cognitive"), action: () => switchTab("cognitive"), - keys: "8", + keys: "9", }, { id: "refresh", @@ -515,6 +533,12 @@ async function init() { ) { window.SchedulesController.init(); } + if ( + window.WeixinController && + typeof window.WeixinController.init === "function" + ) { + window.WeixinController.init(); + } document.querySelectorAll('[data-action="toggle-lang"]').forEach((btn) => { btn.addEventListener("click", () => { diff --git a/src/Undefined/webui/static/js/schedules.js b/src/Undefined/webui/static/js/schedules.js index 4dd1f12d..2effbb2d 100644 --- a/src/Undefined/webui/static/js/schedules.js +++ b/src/Undefined/webui/static/js/schedules.js @@ -166,6 +166,7 @@ task.cron, task.tool_name, task.self_instruction, + task.address, task.target_id, task.target_type, ] @@ -194,9 +195,7 @@ const selected = taskId === scheduleState.selectedId; const mode = modeOfTask(task); const nextRun = formatDateTime(task.next_run_time); - const target = task.target_id - ? `${task.target_type || "group"}:${task.target_id}` - : t("schedules.no_target"); + const target = taskAddress(task) || t("schedules.no_target"); return ` + @@ -761,6 +765,90 @@

表情包库

+ +
+
+
+

微信接入

+

管理 iLink 帐号与逻辑 QQ 身份绑定。

+
+
+ + +
+
+ + + +
+
+ 运行状态 + -- +
+
+ 绑定帐号 + -- +
+
+ 在线帐号 + -- +
+
+ 隔离来源 + -- +
+
+ +
+ +
+
+
+

帐号绑定

+

每个逻辑 QQ 号最多绑定一个微信帐号。

+
+
+
+ + + + + + + + + + + +
别名逻辑身份状态更新时间操作
+ +
+
+ +
+
+
+
+

隔离来源

+

未匹配绑定身份的消息不会进入历史或 AI。

+
+
+
+
+
+
+
+

绑定审计

+

最近的登录、启停、改绑与解绑操作。

+
+
+
+
+
+
+
@@ -830,15 +918,9 @@

定时任务

- - -
-
- - + + +

支持 qq:<QQ号>、group:<群号> 或 wechat:<逻辑QQ号>。

@@ -1044,6 +1126,57 @@

MIT License

+ +