From bfe349cc10b878f5af69ffbea5a35e8b5889c4bf Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 11 Sep 2026 09:38:56 +0800 Subject: [PATCH] feat: improve service configuration and runtime diagnostics --- docs/WebUI-MCP-使用说明.md | 16 +- docs/WebUI-MCP-功能矩阵.md | 14 +- docs/WebUI-MCP-开发计划.md | 2 + docs/WebUI-面向普通用户重构落地指导.md | 330 +++++++++++ docs/validation/WebUI-MCP-2026-09-07.md | 4 +- docs/webui-mcp-config.example.json | 5 +- installer/WxAgent.iss | 3 +- src/WxAgent.Core/WechatLocators.cs | 3 + src/WxAgent.Host/Program.cs | 24 +- src/WxAgent.Host/WindowsAgentBackend.cs | 22 +- src/WxAgent.Service/AgentService.cs | 40 +- src/WxAgent.Service/AgentTools.cs | 7 + src/WxAgent.Service/OperationQueue.cs | 19 +- src/WxAgent.Service/OperationStore.cs | 30 + src/WxAgent.Service/ReadOnlyContracts.cs | 1 + src/WxAgent.Service/RuntimeLog.cs | 65 ++ src/WxAgent.Service/ServiceHost.cs | 37 +- src/WxAgent.Service/ServiceOptions.cs | 33 +- src/WxAgent.Service/ServiceSecurity.cs | 68 ++- src/WxAgent.Service/wwwroot/app.js | 557 ++++++++++++++---- src/WxAgent.Service/wwwroot/index.html | 76 ++- src/WxAgent.Service/wwwroot/styles.css | 2 +- src/WxAgent.Tray/Program.cs | 250 ++++---- .../WechatChatClient.Management.cs | 63 +- tests/WxAgent.Service.Tests/EventPumpTests.cs | 10 +- .../OperationQueueTests.cs | 2 +- .../ServiceBoundaryTests.cs | 131 +++- tests/WxAgent.Service.Tests/ServiceTests.cs | 4 +- 28 files changed, 1427 insertions(+), 391 deletions(-) create mode 100644 docs/WebUI-面向普通用户重构落地指导.md create mode 100644 src/WxAgent.Service/RuntimeLog.cs diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md index 58d2c7f..167a081 100644 --- a/docs/WebUI-MCP-使用说明.md +++ b/docs/WebUI-MCP-使用说明.md @@ -1,9 +1,11 @@ # Web UI + MCP 使用说明 +> 当前版本已实施基础迁移:Token 不按 `AccountIds` 做账号级授权,调用方负责业务隔离;Agent 已校验可信 Host/Origin,并对浏览器写请求执行 CSRF/本机请求头检查。完整的普通用户交互和批次写操作仍按 [普通用户重构指导](WebUI-面向普通用户重构落地指导.md) 分阶段实施。 + ## 启动 1. 在已登录、未锁定的 Windows 用户会话中运行发布包中的 `WxAgent-Setup.exe`。安装器会创建桌面/开始菜单快捷方式和可选的登录启动项,不需要 PowerShell 或管理员权限。 -2. 启动托盘程序后,首次运行会生成远程访问 Token,并在一次性窗口中提供复制按钮。后续通过托盘菜单“访问凭据...”新建或撤销 Token,通过“服务设置...”配置监听和服务参数;不需要手工编辑配置文件。 +2. 启动托盘程序后,首次运行会生成唯一的远程访问 Token,并自动打开“服务设置...”窗口。Token 在该窗口中明文展示,可复制或重新生成;不需要单独的凭据窗口,也不支持多 Token。 3. 如需无托盘运行,仍可复制 `docs/webui-mcp-config.example.json` 后使用: ```powershell @@ -16,9 +18,9 @@ WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json [{"PrincipalId":"local-read","TokenSha256":"<64-hex-sha256>","Permissions":["read"],"AccountIds":[]}] ``` -`AccountIds: []` 不授予任何显式数据库账号范围;需要联系人/群成员等账号范围调用时,必须填入已验证的 account fingerprint。凭据文件应使用当前用户 ACL,禁止提交仓库。 +`AccountIds` 仅为旧凭据格式保留,不再作为账号级数据授权边界;`[]` 可以保留。凭据文件应使用当前用户 ACL,禁止提交仓库。调用方仍必须显式携带 account fingerprint 选择数据和目标,Agent 会验证绑定、窗口和目标一致性。托盘模式的 `service.json` 会保存 `AccessToken` 明文,以便服务设置窗口随时展示;该文件同样只应保存在本机。 -服务运行后使用托盘菜单中的“服务设置...”配置监听地址、端口、远程访问、队列和监听事件;使用“访问凭据...”新建或撤销 Token,不需要手工编辑 `service.json`/`credentials.json`。若需要保持微信 UI 会话不自动锁屏,在安装器的可选项中启用“防止自动锁屏”;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`,本机访问控制台不需要 Token,会自动进入。外部监听必须启用远程访问并自行配置防火墙,服务不会自动开放端口。Host/Origin(CORS)限制不再校验;HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 +服务运行后通过托盘菜单中的“服务设置...”配置监听地址、端口、远程访问和唯一 Token,不需要手工编辑 `service.json`/`credentials.json`。队列容量、监听会话和监听事件开关属于内部实现/验收策略,不作为用户配置项。若需要保持微信 UI 会话不自动锁屏,在安装器的可选项中启用“防止自动锁屏”;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`,本机访问控制台不需要 Token,会自动进入。外部监听必须启用远程访问并自行配置防火墙,服务不会自动开放端口;外部监听使用具体 IP,不能使用 `0.0.0.0`/`::`。服务校验可信 Host/Origin;远程浏览器写请求需要 Cookie、CSRF 和可信 Origin,本机浏览器写请求还需要 `X-WxAgent-Local: 1`。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 远程浏览器访问 `/` 后输入 Token 登录。HTTP/MCP 客户端使用: @@ -26,22 +28,22 @@ WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json Authorization: Bearer ``` -MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。事件流默认关闭;只有在固定测试会话、受控消息验证和恢复验收完成后才设置 `EnableListenerEvents=true`。真机消息验证最多发送 3 条。 +MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session ID、浏览器持久存储或日志中。事件流默认关闭,是否启用属于内部验收开关,不作为用户配置项;真机消息验证最多发送 3 条。 ## 凭据更换与撤销 -在托盘“访问凭据...”中创建新 Token 或撤销旧 Token。明文 Token 只在创建时显示一次;服务在每个请求、任务执行和事件批次重新读取凭据。旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。变更后删除旧浏览器会话并重新登录。 +在托盘“服务设置...”中查看唯一 Token;点击“重新生成”后保存,旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。服务在每个请求、任务执行和事件批次重新读取凭据。 ## 多账号显式绑定 1. 在未锁定的交互式 Windows 会话中读取 `GET /api/v1/accounts` 和 `GET /api/v1/ui-targets`。 2. 用户选择一个数据库 `accountId` 和一个当前窗口 `targetId`(PID+HWND),调用 `POST /api/v1/accounts/bind`;服务会重新读取 UI 微信号/昵称,并与已验证 `contact.db` 身份匹配,重复、过期或不匹配均拒绝。 3. 绑定状态保存在服务数据目录的 `account-bindings.json`。微信进程/窗口消失、身份变化或重启后窗口句柄失效时,必须重新绑定;调用 `POST /api/v1/accounts/unbind` 可主动解除。 -4. 会话、可见消息、联系人等操作必须显式携带已绑定 `accountId`;未绑定返回 `AccountNotBound`/`AccountWindowUnavailable`,不会猜测窗口。 +4. 会话、可见消息、联系人和任务筛选等操作必须显式携带要查看或操作的 `accountId`;它是调用方的数据/目标选择,不是 Token 授权边界。未绑定返回 `AccountNotBound`/`AccountWindowUnavailable`,不会猜测窗口。任务列表按当前 principal 返回,可用 `accountId` 做视图筛选。 ## 只读边界 -状态、账号、窗口目标、会话、摘要消息、联系人和任务查询通过 REST 或同一 MCP 工具访问。正文需 `content` 权限。当前写入、@所有人、管理和朋友圈能力保持 disabled;不会把入队、UI 点击或数据库指纹描述成已发送。 +状态、账号、窗口目标、会话、摘要消息、联系人和任务查询通过 REST 或同一 MCP 工具访问;任务列表使用 `GET /api/v1/operations` 或 MCP `operations_list`,详情/取消仍按当前 principal 校验。正文需 `content` 权限。当前文本发送、@所有人、管理和朋友圈能力保持 disabled;不会把入队、UI 点击或数据库指纹描述成已发送。 ## 回滚 diff --git a/docs/WebUI-MCP-功能矩阵.md b/docs/WebUI-MCP-功能矩阵.md index 19c6d24..8c4dab1 100644 --- a/docs/WebUI-MCP-功能矩阵.md +++ b/docs/WebUI-MCP-功能矩阵.md @@ -3,7 +3,7 @@ 状态以 `GET /api/v1/capabilities` 为准;`implemented` 不代表 `validated`,数据库指纹也不代表当前 UI 账号绑定。 | 操作 | REST | MCP | 当前状态 | 说明 | -|---|---|---|---|---| +| --- | --- | --- | --- | --- | | 服务/微信诊断 | `GET /api/v1/status`、`/api/v1/diagnostics` | `agent_status`、`agent_diagnose` | Ready/环境依赖 | 脱敏;服务在线不等于微信可用,不执行恢复或 UI 写操作 | | 能力清单 | `GET /api/v1/capabilities` | `agent_capabilities` | Ready | 各项 implemented/validated/enabled 分离 | | 数据库账号发现 | `GET /api/v1/accounts` | `accounts_list` | 只读 | 仅返回指纹和绑定状态,不返回密钥 | @@ -11,15 +11,15 @@ | 可见会话 | `GET /api/v1/sessions?accountId=...`、`/search`、`/current`;POST `/open`、`/scroll` | `sessions_list`、`sessions_search`、`session_current` | 只读/导航 | 所有 UI 调用必须携带已绑定 accountId;精确匹配拒绝猜测;open/scroll 需 manage 且当前仍待导航验收 | | 可见消息 | `GET /api/v1/messages?accountId=...` | `messages_read` | 只读 | 必须使用已绑定 accountId;默认摘要;`includeContent` 需要 content 权限 | | 数据库消息/合并记录 | `GET /api/v1/db/messages`、`/api/v1/db/merged` | `db_messages`、`db_merged` | Implemented but disabled | 仅接受显式已验证账号 fingerprint;只读 SQLCipher,未提供通用 SQL | -| 任务查询/取消 | `/api/v1/operations/{id}` | `operation_get`/`operation_cancel` | Ready | 只允许任务所有者;取消不撤销已发生副作用 | +| 任务查询/取消 | `GET /api/v1/operations`、`/api/v1/operations/{id}`、`POST /api/v1/operations/{id}/cancel` | `operations_list`、`operation_get`、`operation_cancel` | Ready | 按 principal 隔离;`accountId` 仅为调用方视图筛选,不是账号授权;取消不撤销已发生副作用 | | 事件流 | `GET /api/v1/events` | 暂未注册 | Explicit opt-in | SSE 有界缓存、Last-Event-ID gap、Windows `ListenEventsAsync` 已接入;默认关闭,需受控真机验收后开启 | -| 文本/文件/卡片发送 | — | — | Disabled | 绑定验证已具备,但写能力仍需目标唯一性、写后确认和真机验收;绝不模拟成功 | -| 联系人/群管理、朋友圈 | — | — | Deferred/Disabled | 遵循 `docs/PENDING.md`,需单项授权和真机证据 | +| 单目标文本发送 | `POST /api/v1/operations`,`kind=send-text` | `operation_submit` | Implemented but disabled | 已接入任务/幂等/写后确认契约;`send-text` 保持 disabled,须完成账号绑定、目标唯一性和 Windows 真机验收后才能开放 | +| 文件/卡片发送、联系人/群管理、朋友圈 | — | — | Deferred/Disabled | 遵循 `docs/PENDING.md`,需单项授权和真机证据 | | 任意 SQL/UI 菜单/shell | — | — | Unsupported | 不提供 | ## 运行边界 -- 可通过 `WxAgent.Host serve --config ` 启动;安装版使用托盘程序,服务设置和 Token 管理均通过系统 UI 完成。默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`。 +- 可通过 `WxAgent.Host serve --config ` 启动;安装版使用托盘程序,服务设置和唯一 Token 均在同一个系统设置窗口完成。默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`。 - 本机回环访问 Web UI、HTTP API 和 Streamable HTTP MCP (`/mcp`) 不需要 Token;远程访问仍使用 Bearer Token,Token 只放 `Authorization`,不放 URL。 -- 远程浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需 CSRF;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。 -- 明文 HTTP 不提供传输保密性,只适合可信隔离网络;不应直接暴露公网。 +- 远程浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需可信 Host/Origin、CSRF;本机浏览器写请求还需同源本机请求头;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。 +- 明文 HTTP 不提供传输保密性,只适合可信隔离网络;不应直接暴露公网。外部监听必须使用具体 IP,不能使用 `0.0.0.0`/`::` 作为工作台地址。 diff --git a/docs/WebUI-MCP-开发计划.md b/docs/WebUI-MCP-开发计划.md index 32474a0..1634d82 100644 --- a/docs/WebUI-MCP-开发计划.md +++ b/docs/WebUI-MCP-开发计划.md @@ -1,5 +1,7 @@ # Web UI + MCP 服务开发计划 +> 后续 UI 改造入口:[面向普通用户重构落地指导](WebUI-面向普通用户重构落地指导.md)。用户界面以连接 Agent、消息收发、受控群发、添加好友和任务结果为主线;本文保留早期服务规划,当前实现状态请结合源码、功能矩阵和验收记录判断。 +> > 状态:规划,尚未实施或验收。用户已确认目标为“浏览器 Web UI + MCP 服务”。 > 本次交付仅为开发计划,不启动服务、不执行微信写操作。 > 范围变化:原第一阶段排除正式 UI;本文规划其后的控制面阶段。用户已确认 HTTP/Web UI 与 MCP 支持外部访问且共用 Token,不要求 HTTPS,不实现服务端人工审批;不改变 Windows Service、协议及数据库安全边界。 diff --git a/docs/WebUI-面向普通用户重构落地指导.md b/docs/WebUI-面向普通用户重构落地指导.md new file mode 100644 index 0000000..7f916f9 --- /dev/null +++ b/docs/WebUI-面向普通用户重构落地指导.md @@ -0,0 +1,330 @@ +# Web UI 面向普通用户重构落地指导 + +> 状态:待实施指导文档,不代表功能已开放或通过验收。 +> 产品定位:普通用户连接 Agent 后,可以查看和回复消息、选择对象群发、添加好友、查看执行结果,无需理解 CLI、MCP、UIA 或数据库。 +> 本文依据当前工作区源码整理;已有未提交修改,不视为已发布版本。本次仅交付文档,不修改运行代码、不开放写权限、不执行微信写操作。 +> +> 待实施决策:认证成功的 Token 可获取 Agent 当前登录账号的数据,不按 `AccountIds` 做账号级授权隔离;调用方负责账号、页面和业务视图隔离。Agent 仍保留能力权限、账号与窗口绑定一致性、目标核验,并补齐 CSRF/Origin 等服务端安全校验。好友申请冷却明确按“发送账号+目标”计算,为 4 小时。这些是目标行为,不代表当前源码已实现。 + +## 1. 改造结论与范围 + +当前 UI 是开发者诊断控制台,目标 UI 应是微信业务工作台。改造不是换配色、加卡片,而是把页面组织方式从“后端有什么接口”改成“用户要完成什么事情”。 + +### 1.1 本轮必须覆盖 + +1. **连接 Agent**:从 Windows 托盘打开工作台,或在另一台设备打开 Agent 提供的地址并输入连接密码。 +2. **消息管理**:选择会话、阅读消息、回复文本、查看发送结果;明确同步范围和时效。 +3. **群发**:选择已有联系人/群、编写文本、核对名单、确认发送、查看逐项结果、停止剩余发送。 +4. **添加好友**:输入微信支持的查找标识、确认搜索对象、填写验证信息、提交好友申请、查看申请结果。 +5. **任务中心**:自动展示当前身份的任务,无需复制粘贴 Operation ID;共享 Token 的调用方共享任务归属,不承诺浏览器用户私有任务。 + +上述是产品交付范围,不意味着现有后端全部支持。群发定义为用户确认名单后的逐对象发送,不是调用微信协议或承诺存在原生群发接口。添加好友首版做单人申请,不做批量陌生人搜集或批量加人。 + +### 1.2 本轮不做 + +- 不引入 CRM、团队客服分配、自动回复机器人、营销自动化、定时发送、多租户、云端中转或多 Agent 并行控制。 +- 不承诺读取全部会话、全部历史或离线消息必达;不实现“下一个未读会话”。 +- 不顺带开放朋友圈、删除好友、移出群成员等高风险操作。 +- 不引入新前端框架、通用工作流引擎、独立消息队列或 Windows Service。 +- 不为简化体验而取消账号身份核验、能力权限、写后确认、串行调度和失败诊断;账号之间的业务视图隔离由调用方处理,不能替代服务端目标一致性校验。 + +本文扩展早期计划中的正式 UI/受控批量发送范围;其余安全及技术约束不变。它不是对 `PENDING.md` 所列暂缓能力的整体放行。 + +## 2. 当前事实:可以复用什么,还缺什么 + +基线文件: + +- `src/WxAgent.Service/wwwroot/index.html`、`app.js`、`styles.css` +- `src/WxAgent.Service/ServiceHost.cs`、`AgentService.cs` +- `src/WxAgent.Service/OperationQueue.cs`、`OperationStore.cs` +- [功能矩阵](WebUI-MCP-功能矩阵.md)、[验收记录](validation/WebUI-MCP-2026-09-07.md)、[暂缓事项](PENDING.md) + +| 领域 | 当前实现 | 改造缺口 | +| --- | --- | --- | +| 页面组织 | 单页纵向排列状态、能力、账号绑定、窗口、会话、联系人、消息、任务查询 | 缺少业务导航、明确主操作和逐步流程 | +| 连接认证 | 本机回环免 Token;远程输入 Token 登录,Cookie + CSRF | 缺少面向用户的启动指引、连接故障处理和就绪状态 | +| 账号选择 | 显式选择数据库账号和微信窗口,界面显示 Fingerprint、PID、HWND | 应显示可识别账号;原始字段移入诊断,身份不明仍禁止绑定 | +| 账号范围 | 当前部分接口仍使用 `AccountIds` 校验,与其他入口不一致 | 目标是不做 Token 账号级授权隔离;统一 REST/MCP、查询/取消/执行及事件入口,保留账号登录状态、绑定与目标一致性校验 | +| 消息 | 列出可见会话和当前可见消息;当前前端没有完整会话点击/回复流程 | 会话导航、正文权限引导、聊天布局、发送接口和结果验证 | +| 联系人/群 | 已有数据库只读分页查询接口;前端加载首批联系人 | 搜索、加载更多、选中名单和稳定目标解析 | +| 导航/历史 | 已有部分路由;导航待验收、数据库消息能力存在禁用项 | 不得把“接口存在”当成“页面可直接使用” | +| 单条发送 | 服务层尚无文本发送入口,功能矩阵标记 Disabled | 接入现有 Windows 业务方法、权限/唯一目标/写后确认、真机验收 | +| 群发/加好友 | 尚无 Web 业务闭环 | 有界批量任务、单人好友申请接口及端到端验收 | +| 任务 | 持久化队列、按 ID 查询/取消、幂等与重启状态处理 | 缺少任务列表、业务结果字段、批量逐项结果;不是只改前端就能补齐 | +| 事件 | 服务端已有 SSE,默认关闭,需显式启用并验收;当前前端主要手动或每 15 秒刷新 | 前端事件接入、断线恢复与缺口提示,不能宣称完整实时收件箱 | + +运行时以 `capabilities` 的实际权限/可用状态为准,结合绑定状态和环境状态判断按钮是否可操作。重构后的 `accountId` 是数据和目标选择/一致性字段,不是 Token 的账号授权边界;调用方可以按账号隔离界面,但不能绕过 Agent 的绑定、目标和写操作校验。 + +## 3. 新信息架构 + +```text +未连接:连接与使用引导 + +已连接工作台 +├── 消息 默认首页:会话列表、消息区、回复框 +├── 群发 新建群发、最近群发结果 +├── 通讯录 联系人 / 群、搜索、添加好友 +├── 任务 最近任务、状态筛选、逐项结果 +└── 设置 + ├── 连接与账号 Agent 地址、当前微信、重新连接/选择账号 + └── 高级设置 权限、能力详情、诊断、MCP、技术标识 +``` + +顶部只保留 Agent 名称/地址、当前微信账号、就绪提示和设置入口。不再用四张技术状态卡占据首页,也不把能力清单当菜单。 + +桌面消息页结构: + +```text +[微信工作台] 当前账号:昵称 [已连接 · 可发送] [设置] +[消息] [群发] [通讯录] [任务] +┌────────────────┬────────────────────────────────┐ +│ 搜索会话 │ 会话名称 / 联系人或群 │ +│ 会话列表 │ 数据范围、最后更新时间 │ +│ 加载更多 │ 消息内容 / 加载失败或空状态 │ +│ ├────────────────────────────────┤ +│ │ 输入消息…… [发送] │ +└────────────────┴────────────────────────────────┘ +``` + +窄屏改为“会话列表 → 消息详情 → 返回”,不硬挤三栏。表单必须有标签、键盘焦点和可读错误提示;成功/失败不能只靠颜色区分。自动刷新不得抢焦点、清空草稿或重置已选名单。 + +## 4. 关键交互流程 + +### 4.1 连接 Agent:让用户不必先学习部署术语 + +**本机路径**:打开 Windows 托盘菜单“打开工作台” → 自动检查连接 → 选择并确认微信账号 → 进入消息页。 + +**远程路径**:在 Agent 托盘设置中开启外部访问、复制工作台地址 → 另一台设备用浏览器打开该地址 → 输入“连接密码” → 确认账号 → 进入消息页。 + +- 首版继续由目标 Agent 托管页面,浏览器同源调用 API。不另造中央门户,也不让页面直接跨域连接任意 Agent。 +- 远程调用方负责当前账号、页面状态、重连和业务视图隔离;Agent 仍负责 Token、请求来源、CSRF/Origin、绑定和目标一致性校验。 +- “连接密码”只是现有访问 Token 的用户界面名称,不改变其高强度随机凭据属性;密码取得/重置位置给出图文指引。 +- 复制地址不得包含 Token;不把凭据保存到 URL、localStorage、日志或示例截图。可记住非敏感显示偏好,不自行延长认证有效期。 +- 默认仍只监听回环。开启外部访问时说明 HTTP 明文只适合可信隔离网络,不能表述为安全公网访问;不要自动开放公网或放宽 CORS。 +- 托盘/设置必须显示并可复制实际可达的工作台地址;监听 `0.0.0.0`/`::` 时不能把通配地址直接当浏览器地址,应提示选择或填写局域网可达地址,并说明防火墙和连通性检查。 +- 地址无法打开属于浏览器加载前故障,指引需同时放在托盘设置/使用说明,不能只放在尚未加载的页面。 + +**账号确认**:优先展示已核验昵称/微信号。只有一个候选也需首次明确确认;多个同名候选用可靠的补充身份区分。身份无法确认时提示去 Windows 打开正确微信并重新检查,不猜测绑定。 + +**状态用自然语言分层显示**: + +| 状态 | 用户文案 / 下一步 | +| --- | --- | +| Agent 不可达 | “无法连接电脑上的 Agent,请确认该电脑开机且 Agent 正在运行。” 提供重试连接 | +| 认证失败/过期 | “连接密码无效或已过期,请到 Agent 设置查看后重新连接。” | +| 微信未就绪 | “请在 Agent 所在电脑登录微信并解锁屏幕。” | +| 未绑定/绑定失效 | “请选择要管理的微信账号。” / “微信账号发生变化,请重新确认。” | +| 可读取但不能写 | “当前只能查看消息。” 在发送入口说明具体限制 | +| 可操作 | “已连接,可以收发消息。” 仅在相应能力和环境都满足时显示 | + +切换账号后清除旧会话及消息视图,草稿未发送时先提示;在途请求必须按账号/会话作用域丢弃过期响应,避免旧数据串入新账号。 + +### 4.2 消息管理:从只读列表到阅读、回复闭环 + +1. 进入消息页,展示当前账号会话;注明“当前可见会话”,不伪装为全量通讯录或完整收件箱。 +2. 选中会话后,通过已验收的导航能力打开目标,再读取对应消息。导航未开放时明确“当前仅能查看微信已打开的会话”,不假切换。 +3. 消息区显示时间、发送方、消息类型及正文;正文未授权时给出“未授权查看正文”,不把每条消息打印成枚举值和 JSON。 +4. 有 `content` 权限时可按已确认的用户偏好显示正文;不得为了聊天外观而默认扩权。UI 快照和数据库历史需注明来源,未完成一致性验证前不无标识混排。 +5. 回复框固定当前账号和目标,用户输入文本后发送。普通回复以点击“发送”为明确提交,不每条增加无意义弹窗;群发、加好友另走确认步骤。 +6. 请求受理后展示“排队中/发送中”,只有写后确认成功才标“已发送”;结果无法确认时显示“请到微信核对,勿重复发送”。不宣称对方已收取或已读。 +7. 失败保留未发送草稿并给下一步;已提交内容与正在编辑的新草稿分离。禁用重复提交不替代服务端幂等。 + +发送前后需再次校验账号与目标,不能让执行时微信碰巧打开的聊天替代用户选中目标。联系人数据库 ID、会话 AutomationId 和名称不是同一种标识,转换必须经后端核验,不能前端按名称拼接。 + +**业务操作不可交错**:账号核验 → 目标导航 → 消息快照读取必须在同一个 UI 调度/跨进程门禁临界区内完成,不能只把各个方法分别排队。发送的账号/目标核验、输入、提交和写后确认同样构成单个执行单元;复用共享内部方法,避免嵌套获取非重入门禁。响应携带实际核验的 `accountId`、目标引用和采集时间;若人工操作或窗口变化导致归属无法确认,读取拒绝返回,已尝试发送则标待核对。两个客户端交错切换会话时,不能把乙会话消息标为甲会话;前端丢弃过期响应不能替代此保证。 + +正文、联系人字段均按文本渲染,禁止通过 `innerHTML` 注入。附件、引用、@ 和历史检索在文本闭环完成后逐项开放;上传成功不等于发送成功。 + +**同步规则**:能力允许时接入 SSE;否则明确低频刷新模式。事件缺口、断线或目标变化时重新读取有界快照并显示可能缺失,不承诺补齐所有漏收消息。刷新不得为扫描所有会话而轮流切换微信窗口。 + +### 4.3 群发:四步向导,不提供参数控制台 + +1. **选对象**:从当前 Token 可获取账号的联系人/群分页搜索、多选;持续显示已选人数和完整可核对名单。使用稳定 ID 去重,不按昵称去重,调用方负责账号视图隔离。 +2. **写内容**:首版纯文本,一个批次相同内容;展示最终发送预览。暂不做 CSV 导入、变量模板、多附件或定时器。 +3. **确认**:展示发送账号、联系人/群数量、名单和正文,提示“逐个发送,停止不能撤回已发消息”。用户点击“确认向 N 个对象发送”后才提交。 +4. **看进度**:自动跳转任务详情,显示总数、已确认成功、失败、待核对、未执行及当前项;支持“停止剩余发送”。 + +服务端必须先冻结并校验目标清单,再有界接纳任务;不能由浏览器循环 POST 发送,更不能 `Promise.all` 并发操纵微信。群发动作仍通过现有 UI 调度/跨进程门禁串行执行;前台切换会话也须排队或明确提示占用,不能打断发送步骤。 + +首版建议默认最多 **20 个对象/批次**,作为待验收的安全容量上限,不是微信允许速率或避免限制的保证。上限、每项超时及发送间隔由 Agent 侧受控配置,界面显示实际限制;禁止随机抖动、换号等规避机制。总耗时不能硬塞进现有单任务最大 300 秒预算,应按逐项预算和批次截止时间明确建模。批次至少持久化父任务与逐项记录:提交时按对象数预留容量;每项有独立状态、幂等键、开始/截止时间和结果证据;取消只在下一个未开始边界生效;父任务汇总不覆盖子项事实,重启时所有活动项按既有重启保护转为取消或待核对。 + +- 每个接收对象保存独立幂等标识和状态;浏览器断开不取消已受理任务,重新连接后可找回。 +- 遇到账号变化、登录/锁屏问题、微信限制提示或发送结果不明,首版停止剩余发送,提示人工核对;不自动重试写动作。 +- 取消只阻止尚未执行项,已开始项按实际证据落状态。存在待核对项时不能显示“全部成功”或“全部未发送”。 +- Agent 重启不自动续发;沿用既有重启保护。重新发送必须由用户核对后新建任务,不重放整个批次。 + +### 4.4 添加好友:单人申请,结果不冒充好友关系 + +入口为“通讯录 → 添加好友”。 + +1. 输入当前微信及后端实际支持的微信号/手机号等标识,前端与后端均校验;不提前承诺所有查找方式。 +2. 显式搜索并展示微信返回的候选身份。没有结果、多个结果、身份无法确认都停止,不能按第一个结果自动申请。 +3. 用户选择对象,填写有长度限制的验证信息,预览后点击“发送好友申请”。 +4. 在任务中心记录“申请已提交/已是好友/失败/结果待核对”;“申请已提交”必须有对应证据,只有实际核验成为好友后才显示“已添加”,底层返回 `Unconfirmed` 时不能映射成成功。 + +**冷却与并发规则**: + +- 用户已明确冷却范围为 **发送账号+目标稳定身份**,时长 **4 小时**;不限制同一账号向其他对象申请,但所有操作仍需串行、显式确认并遵守微信限制。不得以昵称、查询字符串、临时候选引用或 Token 作为冷却键;重新搜索、手机号/微信号别名应解析为同一目标,无法可靠核验稳定身份时不开放申请。 +- 通过认证及能力检查后,先按 principal、账号、操作类型和幂等键查询原请求:相同参数返回原任务,不再次消耗候选或冷却;不同参数返回 `IdempotencyConflict`。这是识别重复请求,不是网络错误后自动重放写动作。 +- 新请求须在同一持久化事务内完成有效候选校验/消耗、冷却与活动占用检查、目标占用和任务接纳;一个账号+目标最多有一个排队或执行中的申请。数据库约束兜底,不能只靠前端按钮或进程内检查。占用冲突返回 `FriendRequestInProgress`;队列满或事务失败不消耗候选和占用。 +- 执行前重新检查账号、目标、能力和冷却。在真正尝试点击提交前,先持久化 `submissionAttemptedAt` 与 `retryAt = submissionAttemptedAt + 4h`,持久化失败不点击。只有排队/搜索或已确认尚未尝试提交的失败、取消、已是好友,释放占用且不启动冷却;在持久化提交标记后崩溃,即使不确定是否点击,也保留冷却并标待核对。 +- 冷却期间新请求返回 `FriendRequestCoolingDown` 和服务端 UTC `retryAt`;重连、刷新、重启、Token 轮换及任务详情清理均不重置冷却。客户端只显示倒计时,不决定到期;检测到时钟回拨时保守拒绝提前到期并提示校时。 +- 冷却到期只解除时间门禁,不自动清除 `Unconfirmed`。同账号+目标存在待核对记录时,新任务仍须由用户核对并显式确认,关联原任务记录;禁止自动续发或因到期自动重试。 + +首版不自动监视对方通过、不批量加人、不绕过验证码/风控/隐私限制。搜索本身可能改变 UI,也必须进入统一调度。出现限制提示即停止并原样归类,用户取消不代表撤回申请。 + +底层库有相关方法也不代表可直接开放。必须先核对实际签名、搜索与申请是否可分步、返回证据及失败语义;无法支持“先预览再确认”时补齐共享业务层,不做假预览。 + +### 4.5 任务中心:不用知道任务 ID + +自动展示当前 principal 的任务,调用方负责按 `accountId` 做业务视图隔离。共享 Token 的多个浏览器/调用方可以查询和请求停止该身份的任务;筛选不是用户私有隔离,界面使用“当前身份的任务”,不误称“仅我可见”。提供类型/状态筛选和分页;点开即看进度、逐项结果及处理建议。任务 ID、关联 ID 和诊断下载放“技术详情”,支持复制用于反馈。 + +首版保留现有身份边界:本机免 Token 的 `local` 与远程 Token 的 principal 不同,不自动合并历史任务或跨 principal 取消。切换入口时提示任务来源;本机页面可显式使用同一 Token 登录以找回远程任务。Token 轮换保持 principal 稳定,旧凭据立即失效,新凭据仍可查询原任务;已因撤销停止的任务不自动续发。共享 principal 下的调用方标记只能用于显示筛选,不能当作认证凭据。 + +| 内部状态 | 用户展示 | 行为约束 | +| --- | --- | --- | +| `Queued` | 等待执行 | 可取消 | +| `Running` | 正在执行 | 可请求停止;不能承诺立即撤销 | +| `Succeeded` | 已完成 | 文本发送和好友申请仍用各自业务结果文案 | +| `Failed` | 未完成 | 显示具体原因;不能默认认定所有失败都可安全重发 | +| `Cancelled` | 已取消/未执行 | 只针对已确认未执行项;批次需保留已执行项结果 | +| `Unconfirmed` | 结果待核对 | 显示核对指引,禁止自动重试 | + +批次的“部分完成/已停止”是逐项结果汇总,需新增汇总契约,不伪装成现有单任务状态。任务列表本身只能返回当前 principal 的记录;账号筛选是调用方的业务视图,不作为 Token 账号授权边界。 + +## 5. 技术实施:沿用现有结构,补真实缺口 + +### 5.1 前端 + +- 保留原生 HTML/CSS/JavaScript 和 Agent 静态托管,首版用 hash 导航即可,不新增构建链或前端框架。 +- `index.html` 改为连接页与工作台骨架;`styles.css` 实现布局、状态和窄屏适配。 +- `app.js` 保留 API/认证入口;当业务代码实际增长时按消息、群发、通讯录、任务拆小模块,不先建通用组件库。 +- API 辅助函数保留结构化 `error.code`、HTTP 状态和 `correlationId`,映射“原因 + 下一步”,未知错误仍有通用提示及诊断编号。 +- 认证失效统一停止刷新/事件流、清除旧私密视图、进入登录。只读请求允许有界重试;写请求不因网络错误自动重放。 +- 请求中的 accountId、目标及幂等键由业务上下文生成并保存到本次操作状态,不要求用户手填;浏览器缓存不是持久任务的事实来源。 +- 浏览器使用下述固定请求安全规则;调用方业务隔离不能替代服务端安全门禁,缺少 Origin 不代表请求来自 CLI。 + +### 5.2 服务与 Windows 业务层 + +| 位置 | 最小改造 | 禁止事项 | +| --- | --- | --- | +| `ServiceHost.cs` | 新增经过校验的业务路由及分页任务列表;统一 Token、会话、CSRF/Origin 和请求来源校验 | 前端直调任意 UI 菜单、Shell 或 SQL | +| `AgentService.cs` / `IAgentBackend` | 共用能力、账号/目标一致性验证;不按 Token 的 `AccountIds` 做账号授权过滤,调用方负责业务隔离;按已核实库方法补文本发送、好友搜索/申请 | 在路由里另写坐标或复制一套微信操作 | +| `OperationQueue.cs` / `OperationStore.cs` | 补任务列表、业务结果;批次/逐项最小持久化关联及停止语义 | 用浏览器内存保存整个群发进度、重启后自动重放 | +| Windows 后端与共享定位器 | 复用既有方法和跨进程门禁;补目标核验与写后证据 | 给单一路由散落坐标补丁、另开并发点击通道 | +| `AgentTools.cs` | 需要开放 MCP 对应能力时薄适配同一服务操作 | 将普通用户页面做成 MCP 工具清单或放宽 MCP 权限 | + +**请求安全规则(待实施,适用于 REST/MCP)**: + +| 请求方式 | 服务端必须执行的规则 | +| --- | --- | +| 远程浏览器 Cookie | 登录提交需匹配配置的精确可信 Origin;登录后非 GET/HEAD 请求同时要求有效会话、绑定会话的 CSRF 请求头和可信 Origin,缺失或 `null` Origin 拒绝。读取/事件请求不产生 UI 导航副作用;有 Origin 时同样校验 | +| 本机回环免 Token 浏览器 | 直连回环且可信 Host 才可初始化短期本机会话及 CSRF;初始化提交同样要求可信 Origin。此后浏览器写请求按 Cookie 规则校验,不沿用“回环直接跳过 CSRF”。只读请求可保留直连回环免 Token 路径 | +| 显式 Bearer(CLI/MCP/HTTP 客户端) | 每次验证 Token 和能力,不依赖 Cookie;非浏览器请求可缺少 Origin,有 Origin 则必须可信。Bearer 无效不能回退到 Cookie 或回环身份;无 Origin 的 HTTP 写调用必须提供 Bearer | + +所有方式均校验配置的可信 Host/端口;可信 Origin 从服务端配置生成,不能照抄未经核验的 Host、Origin 或转发头。`0.0.0.0`/`::` 不进入允许列表;启用远程地址时同步配置精确地址,不用通配 Origin。GET/HEAD 不执行切会话、搜索输入等改变 UI 的操作,这些流程走受保护的 POST。Fetch Metadata 存在时用于附加拒绝跨站请求,缺失时仍按上表判断,不能因普通局域网 HTTP 缺少该头而跳过校验。 + +首版不启用可信反向代理模式、不依据 `Forwarded`/`X-Forwarded-*` 授予回环权限;若代理将远程流量转成本机连接,必须禁用回环免 Token 并要求认证,否则该部署不受支持。Host 校验也覆盖只读接口,防止任意域名通过 DNS 重绑定获得本机访问。CSRF、会话和 Token 均不得放入 URL 或日志。 + +Agent 自己的 `operations.sqlite` 可以保存业务任务元数据;这不改变微信数据库只读边界。尽量只存目标引用、摘要/计数和结果,不保存完整正文、手机号或附件。若可靠执行必须保存敏感任务参数,另行明确最小保留期、访问控制和清理方式,不随日志落盘。 + +**展示清理与防重保护分开**: + +- 任务列表按 `(createdAt, id)` 稳定排序,有界游标分页;终态详情建议默认保留 30 天,活动任务不清理。正文等敏感执行参数在不再需要时优先删除,不随详情保留期延长。 +- 现有幂等摘要位于任务表,不能直接删除整行。详情到期后仍保留最小防重记录(principal、账号、操作类型、幂等键、请求摘要、原任务 ID 和终态);旧键相同参数返回原任务最小状态及 `detailsExpired`,不同参数仍拒绝,不能成为新任务。 +- 首版在建立可验证的请求到期机制前,不自动淘汰最小幂等记录;数量/存储达到 Agent 配置上限时拒绝接纳新任务并给出维护提示,不静默驱逐后重新执行旧键。R0 固化容量值;这避免无界增长,但保守占用存储。以后若支持删除防重记录,必须先保证过期旧请求返回 `IdempotencyExpired`,不能仅凭客户端可修改的时间戳判断。 +- 冷却和待核对保护不随任务详情删除:冷却至少保留至 `retryAt` 且无活动占用,未解决的待核对标记保留至显式核对。清理不能影响 Token 轮换、进程重启后的去重和冷却。 + +### 5.3 契约补齐清单(标注“基础已接入”的入口仍需能力验收后开放) + +| 建议入口 | 用途 | 必须校验/返回 | +| --- | --- | --- | +| `GET /api/v1/operations` | 当前身份的任务列表 | 按当前登录 principal 过滤;可按 `accountId` 筛选但不做 Token 账号授权拒绝;有界分页、摘要,不泄漏其他 principal 的任务 | +| `POST /api/v1/operations`,`kind=send-text` | 单目标文本发送 | **基础已接入但 capability disabled**;accountId、AutomationId 目标引用、text、idempotencyKey、confirmed;受理返回任务 ID,不直接宣称成功;开放前须完成 Windows 真机验收 | +| 同入口,`kind=broadcast-text` | 提交有界群发 | 冻结目标列表、去重、条数/长度限制、显式确认、逐项幂等与批次详情 | +| `POST /api/v1/friend-search` | 好友候选预览 | Token/能力、绑定状态、超时、候选归属当前账号;不发送申请 | +| `POST /api/v1/operations`,`kind=friend-request` | 单人好友申请 | 当前账号绑定的有效候选引用、验证信息、显式确认、幂等键;按 §4.4 原子接纳并执行,发送账号+目标稳定身份冷却 4 小时,返回任务或冷却 `retryAt` | + +请求采用明确的分支 DTO,不接受任意 capability 名加任意参数字典执行未知动作。确认绑定实际账号、目标和内容;修改名单/正文后需重新确认。服务端确认字段是请求门禁,不是服务端人工审批,也不能成为绕过权限的凭据。好友搜索返回的候选必须由服务端生成短期有效、绑定账号和 principal 的不可伪造引用;新请求的候选过期、跨账号或已被消费时拒绝。相同幂等键的已受理请求先返回原任务,不因候选后来过期或已消费改成失败。 + +复用现有 `/operations/{id}` 查询和 `/cancel` 入口。任务详情需扩展业务结果及有界逐项分页,不能返回无界完整群发名单。涉及 UI 的预览/搜索在预计耗时超出普通请求预算时也走任务,不无限挂起 HTTP 请求。 + +## 6. 分阶段落地与完成门槛 + +各阶段应形成可独立验证的提交;完成界面骨架不等于完成产品。写能力逐项验收、逐项开启,不一次性翻转所有 disabled 标志。 + +| 阶段 | 交付 | 验收门槛 / 依赖 | +| --- | --- | --- | +| R0:能力核对 | 核对实际 Windows 方法、导航和写后证据,更新能力差距清单,确定测试账号/目标 | 每个用户动作有后端落点;未实现、未验收、未授权原因分开 | +| R1:工作台与连接 | 新导航、托盘入口文案、账号确认、自然语言状态、诊断下沉 | 普通用户不看 PID/HWND/Fingerprint/JSON 即能连接并确认账号;仍明确只读限制 | +| R2:消息闭环 | 会话选择、消息阅读、文本回复、最小任务列表/详情 | 单条发送唯一目标、幂等、写后确认、取消/超时/断网核对通过;依赖导航和文本写能力验收 | +| R3:受控群发 | 四步向导、批次逐项持久化、进度与停止 | 复用 R2;少量授权对象验证去重、顺序、停止、部分结果、重启不重放 | +| R4:添加好友 | 搜索预览、验证信息、单人申请、真实业务结果 | 专用测试对象获得授权且真机通过;没有获准对象时保留禁用并记录阻塞,不能模拟验收 | +| R5:收尾交付 | 可用性走查、错误/空状态、窄屏和键盘操作、说明与发布回归 | 普通用户完成三条业务主路径;更新矩阵、截图及对应微信版本验收记录 | + +R3、R4 可分别评审,但同一桌面的真机 UI 写操作不得并行。R2 之前不要先做高级模板、主题系统或复杂统计图。 + +## 7. 测试与验收清单 + +### 7.1 自动检查 + +复用现有测试项目;本次是指导文档,以下命令在后续代码实施时执行,不代表本次已执行。 + +```bash +dotnet test tests/WxAgent.Core.Tests -c Release +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true +``` + +另运行当前 Service 的实际路由/鉴权测试项目,不用假路由代替集成测试。最少覆盖: + +- 任务列表按 principal 隔离、`accountId` 筛选正确;同一 Token 可获取全部已登录账号数据;失效 Token、CSRF/Origin 和未授权正文/写操作仍拒绝。 +- 文本边界、群发上限、稳定 ID 去重、同幂等键相同/不同请求、账号目标不匹配。 +- 停止未执行项、执行中取消导致待核对、部分完成汇总、重启不重放。 +- 好友候选过期/跨账号/多候选拒绝;两个不同幂等键并发申请同账号+目标只有一个可接纳,重新搜索或别名查询不能绕过;同键同参数返回原任务、不同参数冲突。 +- 冷却测试覆盖同账号同目标拒绝、不同目标不被此冷却阻止、提交前失败不计时、提交标记持久化后崩溃保守计时;UTC 到期边界、时钟回拨、重启/Token 轮换、`retryAt` 和待核对人工门禁。时钟测试使用可控时间,不需真机等待四小时或重复发送申请。 +- 详情清理后旧幂等请求不重新执行、不同参数仍冲突、容量满拒绝新任务;活动任务、未到期冷却和待核对保护不被清理。 +- 本机免 Token 与 Token 登录的 principal 切换、共享 Token 的多个调用方任务可见/可取消范围、Token 轮换保留归属但不续发。 +- 两客户端交错切会话、搜索和读取:返回实际账号/目标,不能发生甲页面收到乙消息;身份核验与发送之间不能插入其他 Agent UI 操作。 +- Cookie 写请求缺失/伪造 CSRF、Origin 缺失/为 null/跨站均拒绝;Fetch Metadata 缺失的合法局域网浏览器仍可操作;伪造 Host、DNS 重绑定式 Host 和转发头不能获得回环权限;有效无 Origin Bearer 客户端正常,无效 Bearer 不回退。 +- 前端账号/会话快速切换丢弃旧响应;刷新保留草稿和名单;错误提示在未登录页也可见。 +- HTML/脚本样式的消息或昵称只作为文本展示;浏览器存储、诊断及日志中无敏感凭据和完整正文。 + +### 7.2 Windows 真机验收 + +每次 Windows 发布仍执行: + +```powershell +WxAgent.Host doctor +WxAgent.Host inspect-ui --output artifacts/ui-tree.json +WxAgent.Host smoke +``` + +然后执行本次功能对应的真机业务 smoke。`inspect-ui` 产物按敏感诊断管理,不直接作为普通用户可下载的原始树。真机连接和上传流程继续遵守 [AGENTS.md](../AGENTS.md),不得删除或改写其中的连接信息。 + +默认消息测试对象仅限“文件传输助手”“Hao 豪”“吉祥三宝”“消息测试专用群组”;不得为测试群发追加真实联系人。添加好友必须使用用户明确授权的专用未添加账号,没有对象即记为验收阻塞。只读联系人权限不是发送授权。 + +每份记录包含:代码版本、微信版本、Agent 运行会话、绑定账号核验结果、测试步骤、预期/实际结果、脱敏证据、超时/取消/断网/锁屏表现和剩余限制。基础 `doctor/smoke` 成功不能代替群发/加好友验收。 + +### 7.3 普通用户验收场景 + +让未参与开发的人按正常提示完成,不由开发者口头补充技术术语: + +- 从托盘打开页面;远程用户按地址和连接密码指引成功连接,选择正确账号。 +- 找到指定会话、阅读并回复一条文本;明确知道“已发送”还是“结果待核对”。 +- 选择两个获准对象完成一次群发;能核对名单并找到每个对象的结果。 +- 在一个包含剩余项的测试批次中请求停止,能区分已发送和未执行。 +- 向获准测试账号提交好友申请,理解“申请已提交”不等于“对方已通过”。 +- 断线、锁屏、权限不足、账号变化时,能根据提示完成下一步而不是复制 JSON 求助。 +- 无需输入任务 ID 即可找回任务;需要求助时能复制诊断编号,不暴露消息正文。 + +可用性目标:在 Agent 已安装、网络可达、微信已登录且账号准备完成的前提下,首次连接与账号确认力争 3 分钟内完成;这是待测目标,不是已有成绩。 + +## 8. 文档与发布同步 + +- 本文负责产品定位、交互路径和实施顺序。账号不按 `AccountIds` 授权、可信 Host/Origin 与浏览器写请求安全已完成基础迁移;普通用户交互、批次写操作和真机验收仍按本计划推进。使用说明和功能矩阵已同步当前基础行为,未通过真机验收的能力不能标记为可用。 +- [Web UI + MCP 开发计划](WebUI-MCP-开发计划.md) 保留服务架构与安全约束;早期“尚未实施”和暂定目录属于历史阶段信息,不能覆盖当前源码事实。 +- [功能矩阵](WebUI-MCP-功能矩阵.md) 逐项更新已实现/已验收/已开放状态;只在真机通过后标记可用。 +- [使用说明](WebUI-MCP-使用说明.md) 在功能落地时按“连接 → 消息 → 群发 → 添加好友 → 查看结果”重排,MCP/Token 技术说明移到高级章节。 +- `docs/validation/` 保存分阶段验收记录;`PENDING.md` 继续记录未闭环项。未经批准的测试或缺乏真机证据的功能保持禁用。 + +**最终完成标准:用户不懂 Agent 内部技术,也能完成业务;执行器仍严格遵守原有安全和真实性约束。仅把技术字段隐藏、把按钮做漂亮,不算本次重构完成。** diff --git a/docs/validation/WebUI-MCP-2026-09-07.md b/docs/validation/WebUI-MCP-2026-09-07.md index dda4bef..5e56d76 100644 --- a/docs/validation/WebUI-MCP-2026-09-07.md +++ b/docs/validation/WebUI-MCP-2026-09-07.md @@ -11,7 +11,7 @@ ## 已验证 | 能力 | 入口 | 结果 | -|---|---|---| +| --- | --- | --- | | 服务/微信诊断 | `GET /api/v1/status` | HTTP 200;`serviceOnline=true`、`wechatAvailable=true`、`sessionAvailable=true`、`windowFound=true`,错误为空 | | 会话列表 | `GET /api/v1/sessions?accountId=...` | HTTP 200;返回可见会话,含 automationId;未携带 accountId 拒绝执行 | | 会话当前/精确搜索 | `/api/v1/sessions/current?accountId=...`、`/search?accountId=...&exactOnly=true` | 需要显式绑定账号;当前锁屏环境不作为真机成功证据 | @@ -52,6 +52,6 @@ ## 未完成/不宣称 - 未执行发送、联系人/群管理、朋友圈、语音等写操作;`send-text`、`group-at-all` 和 deferred 项保持禁用。 -- 未完成第二个账号/第二个窗口隔离、60 分钟/20 条消息监听、断线补齐和多客户端慢消费者真机验收;SSE 与服务端监听源已实现,默认配置 `EnableListenerEvents=false`。自动化高频探针已中止,不作为验收证据;后续真机消息验证上限 3 条。 +- 未完成第二个账号/第二个窗口隔离、60 分钟/20 条消息监听、断线补齐和多客户端慢消费者真机验收;SSE 与服务端监听源已实现,内部监听事件开关默认关闭,不作为用户配置项。自动化高频探针已中止,不作为验收证据;后续真机消息验证上限 3 条。 - 未完成外部主机直接访问验收(防火墙未开放);不将 HTTP 鉴权描述为网络加密。 - 远程验证任务已停止并清理;不再自动发送测试消息,后续真机消息验证上限为 3 条。 diff --git a/docs/webui-mcp-config.example.json b/docs/webui-mcp-config.example.json index 66ec4be..5ffca77 100644 --- a/docs/webui-mcp-config.example.json +++ b/docs/webui-mcp-config.example.json @@ -2,8 +2,5 @@ "ListenUrl": "http://127.0.0.1:5088", "AllowExternal": false, "CredentialFile": "C:/Users/USERNAME/wx-agent/credentials.json", - "DataDirectory": "C:/Users/USERNAME/wx-agent/data", - "QueueCapacity": 100, - "ListenerSession": "文件传输助手", - "EnableListenerEvents": false + "DataDirectory": "C:/Users/USERNAME/wx-agent/data" } diff --git a/installer/WxAgent.iss b/installer/WxAgent.iss index 7ce7381..8e42157 100644 --- a/installer/WxAgent.iss +++ b/installer/WxAgent.iss @@ -7,7 +7,8 @@ AppName=WxAgent AppVersion={#AppVersion} AppPublisher=WxAgent SetupIconFile={#PublishRoot}\wxagent.ico -DefaultDirName={localappdata}\WxAgent +DefaultDirName={localappdata}\Programs\WxAgent +DisableDirPage=no DefaultGroupName=WxAgent OutputDir=..\artifacts\setup OutputBaseFilename=WxAgent-Setup diff --git a/src/WxAgent.Core/WechatLocators.cs b/src/WxAgent.Core/WechatLocators.cs index 149491d..7b28115 100644 --- a/src/WxAgent.Core/WechatLocators.cs +++ b/src/WxAgent.Core/WechatLocators.cs @@ -4,6 +4,9 @@ public static class WechatLocators { public const string MainView = "MainView"; public const string MainTabBar = "MainView.main_tabbar"; + public const string ProfileWindowTitle = "Weixin"; + public const string ProfileDisplayName = "right_v_view.nickname_button_view.display_name_text"; + public const string ProfileWechatId = "right_v_view.user_info_center_view.basic_line_view.ProfileTextView"; // Observed with WeChat 4.1.13.63; query its public shell icon metadata, never post tray callback messages. public const string TrayMessageWindowClass = "Qt51514WxTrayIconMessageWindowClass"; public const uint TrayIconId = 0; diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index a93f5e4..dff18e0 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -27,10 +27,12 @@ try if (args[0] == "serve") { ValidateOptions(args, 1, ["--config"], []); + var configPath = Path.GetFullPath(GetRequiredOption(args, "--config")); var serviceOptions = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(GetRequiredOption(args, "--config"), shutdown.Token), ServiceHost.Json) + await File.ReadAllTextAsync(configPath, shutdown.Token), ServiceHost.ConfigurationJson) ?? throw new ArgumentException("A service configuration is required."); - await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(serviceOptions, new AccountBindingStore(serviceOptions))); + await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(new AccountBindingStore(serviceOptions)), + logPath: Path.Combine(Path.GetDirectoryName(configPath)!, "wxagent.log")); await app.StartAsync(shutdown.Token); try { await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token); } catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { } @@ -670,27 +672,45 @@ try } catch (OperationCanceledException) when (!shutdown.IsCancellationRequested) { + RuntimeLog.Append(GetRuntimeLogPath(args), Microsoft.Extensions.Logging.LogLevel.Warning, "WxAgent.Host", "Host command timed out."); WriteJson(new { error = WxAgentErrorCode.Timeout.ToString(), message = "Operation timed out." }); return 124; } catch (OperationCanceledException) { + RuntimeLog.Append(GetRuntimeLogPath(args), Microsoft.Extensions.Logging.LogLevel.Information, "WxAgent.Host", "Host command cancelled."); WriteJson(new { error = WxAgentErrorCode.OperationCancelled.ToString(), message = "Operation cancelled." }); return 130; } catch (WxAgentException exception) { + RuntimeLog.Append(GetRuntimeLogPath(args), Microsoft.Extensions.Logging.LogLevel.Error, "WxAgent.Host", "Host command failed.", exception); WriteJson(new { error = exception.Code.ToString(), exception.Message }); return 1; } catch (Exception exception) { + RuntimeLog.Append(GetRuntimeLogPath(args), Microsoft.Extensions.Logging.LogLevel.Error, "WxAgent.Host", "Host command failed.", exception); WriteJson(new { error = WxAgentErrorCode.InvalidOperationState.ToString(), message = exception.Message }); return 1; } void WriteJson(T value) => Console.WriteLine(JsonSerializer.Serialize(value, jsonOptions)); +static string GetRuntimeLogPath(string[] values) +{ + try + { + var config = GetOption(values, "--config"); + var directory = config is null ? AppContext.BaseDirectory : Path.GetDirectoryName(Path.GetFullPath(config)) ?? AppContext.BaseDirectory; + return Path.Combine(directory, "wxagent.log"); + } + catch + { + return Path.Combine(AppContext.BaseDirectory, "wxagent.log"); + } +} + static int ValidateCommandLine(string[] values) { if (values[0] == "doctor") diff --git a/src/WxAgent.Host/WindowsAgentBackend.cs b/src/WxAgent.Host/WindowsAgentBackend.cs index ce3ec07..9755829 100644 --- a/src/WxAgent.Host/WindowsAgentBackend.cs +++ b/src/WxAgent.Host/WindowsAgentBackend.cs @@ -4,8 +4,9 @@ using WxAgent.Windows; namespace WxAgent.Host; -public sealed class WindowsAgentBackend(ServiceOptions options, AccountBindingStore bindings) : IAgentBackend, IAgentEventSource +public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBackend, IAgentEventSource { + private const bool ListenerEventsEnabled = false; private readonly SemaphoreSlim bindingGate = new(1, 1); public IReadOnlyList Capabilities { get; } = @@ -24,8 +25,8 @@ public sealed class WindowsAgentBackend(ServiceOptions options, AccountBindingSt new("db-messages", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.63"), new("db-merged", true, false, false, false, false, "read", false, 30, "Database key/account query acceptance is pending.", ["Requires explicit verified account key scope."], "4.1.13.63"), new("group-members", true, true, true, false, false, "read", false, 30, null, ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), - new("listener-events", true, options.EnableListenerEvents, options.EnableListenerEvents, true, false, "read", false, 30, - options.EnableListenerEvents ? null : "Listener events require explicit opt-in and recovery validation.", ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), + new("listener-events", true, ListenerEventsEnabled, ListenerEventsEnabled, true, false, "read", false, 30, + ListenerEventsEnabled ? null : "Listener events are reserved for controlled validation.", ["docs/validation/WebUI-MCP-2026-09-07.md"], "4.1.13.63"), new("send-text", true, false, false, true, true, "write", false, 30, "Active UI/database account binding has not been verified for this service session.", []), new("group-at-all", true, false, false, true, true, "write", true, 30, @@ -52,7 +53,7 @@ public sealed class WindowsAgentBackend(ServiceOptions options, AccountBindingSt while (!cancellationToken.IsCancellationRequested) { await foreach (var item in WechatChatClient.ListenEventsAsync(TimeSpan.FromMinutes(5), checkpoint, cancellationToken, - session: options.ListenerSession)) + session: WechatLocators.FileTransferAssistant)) { yield return new AgentEvent(item.EventId, binding.AccountId, item.Session, item.Kind.ToString(), item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt); @@ -276,6 +277,19 @@ public sealed class WindowsAgentBackend(ServiceOptions options, AccountBindingSt public Task> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) => ForAccountAsync(accountId, cancellationToken, () => MessagesAsync(session, includeContent, cancellationToken)); + public async Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken) + { + await ForAccountAsync(accountId, cancellationToken, async () => + { + var matches = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken)) + .Where(session => string.Equals(session.AutomationId, targetId, StringComparison.Ordinal)).ToArray(); + if (matches.Length != 1) + throw new ServiceException(matches.Length == 0 ? "NotFound" : "AmbiguousTarget", 409, "The selected session is not unique or no longer visible."); + await WechatChatClient.SendTextAsync(text, cancellationToken, matches[0].Name); + return true; + }); + } + private async Task ForAccountAsync(string? accountId, CancellationToken cancellationToken, Func> action) { if (string.IsNullOrWhiteSpace(accountId)) diff --git a/src/WxAgent.Service/AgentService.cs b/src/WxAgent.Service/AgentService.cs index 854ed28..0154118 100644 --- a/src/WxAgent.Service/AgentService.cs +++ b/src/WxAgent.Service/AgentService.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using WxAgent.Core; namespace WxAgent.Service; @@ -28,6 +29,7 @@ public interface IAgentBackend Task ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) => ScrollSessionsAsync(direction, pages, cancellationToken); Task> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) => Task.FromResult>([]); Task> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) => MessagesAsync(session, includeContent, cancellationToken); + Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Text sending is not available."); Task> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page([], limit, offset, false, null)); Task> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page([], limit, offset, false, null)); Task> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken) => Task.FromResult>([]); @@ -41,12 +43,13 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security internal void RequireEvents() => RequireCapability("listener-events"); - private void RequireCapability(string operation) + private AgentCapability RequireCapability(string operation) { var capability = backend.Capabilities.SingleOrDefault(c => c.Operation == operation) ?? throw new ServiceException("Unsupported", 501, "Capability is not implemented."); security.RequireCurrent(Identity, capability.Permission); if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability is disabled."); + return capability; } public async Task DiagnoseAsync(CancellationToken cancellationToken) @@ -130,6 +133,34 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security public Task> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) => MessagesAsync(null, session, limit, offset, includeContent, ct); + public OperationRecord SubmitOperation(OperationSubmitRequest request, CancellationToken ct) + { + if (!string.Equals(request.Kind, "send-text", StringComparison.Ordinal)) + throw new ServiceException("UnsupportedOperation", 400, "Only send-text is available in this phase."); + var accountId = RequireText(request.AccountId, "accountId", 200); + var targetId = RequireText(request.TargetId, "targetId", 512); + var text = PrepareText(request.Text); + var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128); + if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required."); + var capability = RequireCapability("send-text"); + var canonical = System.Text.Json.JsonSerializer.Serialize(new { request.Kind, accountId, targetId, text }); + return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical, + cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation)); + } + + private static string RequireText(string? value, string name, int maxLength) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > maxLength) + throw new ServiceException("InvalidRequest", 400, $"{name} is required and bounded."); + return value; + } + + private static string PrepareText(string? value) + { + try { return WechatTextInput.Prepare(value ?? string.Empty); } + catch (WxAgentException exception) { throw new ServiceException("InvalidRequest", 400, exception.Message); } + } + public async Task> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken ct) { RequireCapability("contacts-list"); @@ -165,6 +196,13 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security public FileStream Download(string id) { security.RequireCurrent(Identity, "content"); return artifacts.Open(Identity.PrincipalId, id); } + public Page Operations(string? accountId, int limit, int offset) + { + ReadOnlyRequest.Page(limit, offset); + if (accountId is { Length: > 200 }) throw new ServiceException("InvalidRequest", 400, "accountId is too long."); + return operations.List(Identity, accountId, limit, offset); + } + public OperationRecord Operation(string id) => operations.Get(Identity, id); public OperationRecord CancelOperation(string id) => operations.Cancel(Identity, id); diff --git a/src/WxAgent.Service/AgentTools.cs b/src/WxAgent.Service/AgentTools.cs index 93d554f..d50b0cd 100644 --- a/src/WxAgent.Service/AgentTools.cs +++ b/src/WxAgent.Service/AgentTools.cs @@ -54,6 +54,13 @@ public sealed class AgentTools(AgentService service) [McpServerTool(Name = "db_merged", ReadOnly = true), Description("Read one bounded merged database record for an explicitly verified account fingerprint; disabled until key scope is validated.")] public Task DatabaseMerged(string accountId, string chatId, long localId, CancellationToken cancellationToken = default) => Result(async () => await service.DatabaseMergedAsync(accountId, chatId, localId, cancellationToken)); + [McpServerTool(Name = "operations_list", ReadOnly = true), Description("List bounded operations owned by the current identity; accountId is a caller-side view filter, not an authorization boundary.")] + public Task Operations(string? accountId = null, int limit = 50, int offset = 0) => Result(() => Task.FromResult(service.Operations(accountId, limit, offset))); + + [McpServerTool(Name = "operation_submit"), Description("Submit an explicitly confirmed operation. send-text remains disabled until Windows validation is complete.")] + public Task SubmitOperation(string kind, string accountId, string targetId, string? text, string idempotencyKey, bool confirmed = false, CancellationToken cancellationToken = default) => + Result(() => Task.FromResult(service.SubmitOperation(new OperationSubmitRequest(kind, accountId, targetId, text, idempotencyKey, confirmed), cancellationToken))); + [McpServerTool(Name = "operation_get", ReadOnly = true), Description("Read one operation owned by the current identity; terminal writes are never replayed.")] public Task Operation(string operationId) => Result(() => Task.FromResult(service.Operation(operationId))); diff --git a/src/WxAgent.Service/OperationQueue.cs b/src/WxAgent.Service/OperationQueue.cs index f03a505..105d854 100644 --- a/src/WxAgent.Service/OperationQueue.cs +++ b/src/WxAgent.Service/OperationQueue.cs @@ -6,11 +6,12 @@ using Microsoft.Extensions.Hosting; namespace WxAgent.Service; -public sealed class OperationQueue(OperationStore store, ServiceOptions options, ServiceSecurity security) : BackgroundService +public sealed class OperationQueue(OperationStore store, ServiceSecurity security) : BackgroundService { + private const int QueueCapacity = 100; private sealed record Work(OperationRecord Record, ServiceIdentity Identity, string Permission, Func Action, CancellationTokenSource Cancel); - private readonly Channel queue = Channel.CreateBounded(new BoundedChannelOptions(options.QueueCapacity) + private readonly Channel queue = Channel.CreateBounded(new BoundedChannelOptions(QueueCapacity) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); private readonly ConcurrentDictionary cancellations = new(); private readonly object admissionGate = new(); @@ -29,7 +30,7 @@ public sealed class OperationQueue(OperationStore store, ServiceOptions options, { if (stopping) throw new ServiceException("Unavailable", 503, "Agent is stopping."); var (record, created) = store.Enqueue(identity.PrincipalId, accountId, capability.Operation, - idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), options.QueueCapacity); + idempotencyKey, digest, capability.HasSideEffects, TimeSpan.FromSeconds(capability.TimeoutSeconds), QueueCapacity); if (!created) return record; var cancel = new CancellationTokenSource(); cancellations[record.Id] = cancel; @@ -44,12 +45,18 @@ public sealed class OperationQueue(OperationStore store, ServiceOptions options, } } + public Page List(ServiceIdentity identity, string? accountId, int limit, int offset) + { + security.RequireCurrent(identity, "read"); + var page = store.List(identity.PrincipalId, accountId, limit, offset); + return new Page(page.Items.Select(OperationSummary.From).ToArray(), page.Limit, + page.Offset, page.HasMore, page.NextOffset); + } + public OperationRecord Get(ServiceIdentity identity, string id) { security.RequireCurrent(identity, "read"); - var record = store.Get(id, identity.PrincipalId); - security.RequireCurrent(identity, "read", record.AccountId); - return record; + return store.Get(id, identity.PrincipalId); } public OperationRecord Cancel(ServiceIdentity identity, string id) diff --git a/src/WxAgent.Service/OperationStore.cs b/src/WxAgent.Service/OperationStore.cs index 0a1f689..49d5d66 100644 --- a/src/WxAgent.Service/OperationStore.cs +++ b/src/WxAgent.Service/OperationStore.cs @@ -6,6 +6,15 @@ public sealed record OperationRecord(string Id, string PrincipalId, string Accou string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, string? ErrorCode, bool HasSideEffects); +public sealed record OperationSummary(string Id, string AccountId, string Capability, + string State, string Stage, string CorrelationId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, + string? ErrorCode, bool HasSideEffects) +{ + public static OperationSummary From(OperationRecord operation) => new(operation.Id, operation.AccountId, + operation.Capability, operation.State, operation.Stage, operation.CorrelationId, operation.CreatedAt, + operation.ExpiresAt, operation.ErrorCode, operation.HasSideEffects); +} + public sealed class OperationStore : IDisposable { private readonly SqliteConnection database; @@ -92,6 +101,27 @@ public sealed class OperationStore : IDisposable } } + public Page List(string principal, string? accountId, int limit, int offset) + { + lock (gate) + { + using var command = database.CreateCommand(); + command.CommandText = string.IsNullOrWhiteSpace(accountId) + ? "SELECT * FROM operations WHERE principal=$principal ORDER BY created DESC, id DESC LIMIT $limit OFFSET $offset" + : "SELECT * FROM operations WHERE principal=$principal AND account=$account ORDER BY created DESC, id DESC LIMIT $limit OFFSET $offset"; + command.Parameters.AddWithValue("$principal", principal); + if (!string.IsNullOrWhiteSpace(accountId)) command.Parameters.AddWithValue("$account", accountId); + command.Parameters.AddWithValue("$limit", limit + 1); + command.Parameters.AddWithValue("$offset", offset); + using var reader = command.ExecuteReader(); + var items = new List(); + while (reader.Read()) items.Add(Read(reader)); + var hasMore = items.Count > limit; + if (hasMore) items.RemoveAt(items.Count - 1); + return new Page(items, limit, offset, hasMore, hasMore ? offset + limit : null); + } + } + public void Transition(string id, string state, string stage, string? error = null) { lock (gate) diff --git a/src/WxAgent.Service/ReadOnlyContracts.cs b/src/WxAgent.Service/ReadOnlyContracts.cs index c6c56d1..2aca131 100644 --- a/src/WxAgent.Service/ReadOnlyContracts.cs +++ b/src/WxAgent.Service/ReadOnlyContracts.cs @@ -5,6 +5,7 @@ public sealed record AccountInfo(string AccountId, string? DisplayName, string? public sealed record SessionInfo(string Name, string AutomationId, bool IsCurrent); public sealed record MessageInfo(string Fingerprint, string Type, string? Sender, string? Summary, string? Content); public sealed record ListRequest(int Limit = 50, int Offset = 0, bool IncludeContent = false, string? AccountId = null, string? Session = null); +public sealed record OperationSubmitRequest(string? Kind, string? AccountId, string? TargetId, string? Text, string? IdempotencyKey, bool Confirmed); public sealed class ReadOnlyRequest { diff --git a/src/WxAgent.Service/RuntimeLog.cs b/src/WxAgent.Service/RuntimeLog.cs new file mode 100644 index 0000000..2888bc7 --- /dev/null +++ b/src/WxAgent.Service/RuntimeLog.cs @@ -0,0 +1,65 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace WxAgent.Service; + +public static class RuntimeLog +{ + private static readonly object Gate = new(); + + public static void Append(string path, LogLevel level, string category, string message, Exception? exception = null) + { + try + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + var line = Format(level, category, message, exception); + lock (Gate) + { + using var stream = new FileStream(fullPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); + using var writer = new StreamWriter(stream, new UTF8Encoding(false)); + writer.WriteLine(line); + } + } + catch + { + // Logging must not prevent the agent from starting or serving requests. + } + } + + internal static string Format(LogLevel level, string category, string message, Exception? exception) + { + var line = $"{DateTimeOffset.Now:O} [{level}] {category}: {message}"; + return exception is null ? line : $"{line}{Environment.NewLine}{exception}"; + } +} + +internal sealed class FileLoggerProvider(string path) : ILoggerProvider +{ + private readonly string path = Path.GetFullPath(path); + + public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName); + + public void Dispose() { } + + private sealed class FileLogger(FileLoggerProvider provider, string categoryName) : ILogger + { + public IDisposable BeginScope(TState state) where TState : notnull => NoopScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) return; + RuntimeLog.Append(provider.path, logLevel, categoryName, formatter(state, exception), exception); + } + + private sealed class NoopScope : IDisposable + { + public static readonly NoopScope Instance = new(); + public void Dispose() { } + } + } +} diff --git a/src/WxAgent.Service/ServiceHost.cs b/src/WxAgent.Service/ServiceHost.cs index 8426d68..b6c6a80 100644 --- a/src/WxAgent.Service/ServiceHost.cs +++ b/src/WxAgent.Service/ServiceHost.cs @@ -12,17 +12,35 @@ public static class ServiceHost { public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; + public static readonly JsonSerializerOptions ConfigurationJson = new(Json) + { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }; public static WebApplication Build(ServiceOptions options, IAgentBackend backend, - Action? configure = null) + Action? configure = null, string? logPath = null) { - options.Validate(); var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], WebRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot") }); + var runtimeLogPath = logPath ?? Path.Combine(AppContext.BaseDirectory, "wxagent.log"); + var fileLogger = new FileLoggerProvider(runtimeLogPath); + builder.Logging.ClearProviders(); + builder.Logging.AddProvider(fileLogger); + try + { + options.Validate(); + } + catch (Exception exception) + { + RuntimeLog.Append(runtimeLogPath, LogLevel.Error, "WxAgent.Service", "Service configuration validation failed.", exception); + fileLogger.Dispose(); + throw; + } + RuntimeLog.Append(runtimeLogPath, LogLevel.Information, "WxAgent.Service", $"Service configured for {options.ListenUrl}."); builder.WebHost.UseUrls(options.ListenUrl); builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 1024 * 1024); - // No framework request logging: URLs, headers and backend exception messages may contain private data. - builder.Logging.ClearProviders(); + // Keep operational logs beside the executable; credentials, headers and request bodies are not logged. + builder.Logging.SetMinimumLevel(LogLevel.Information); + builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.None); + builder.Logging.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.None); builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow); builder.Services.AddSingleton(options); builder.Services.AddSingleton(backend); @@ -43,6 +61,8 @@ public static class ServiceHost app.Use(async (context, next) => { var correlationId = Guid.NewGuid().ToString("N"); + var logger = context.RequestServices.GetRequiredService().CreateLogger("WxAgent.Http"); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); context.Response.Headers["X-Correlation-Id"] = correlationId; context.Response.Headers.CacheControl = "no-store"; context.Response.Headers["X-Content-Type-Options"] = "nosniff"; @@ -70,9 +90,16 @@ public static class ServiceHost OperationCanceledException => ("Cancelled", 408, "Request cancelled or timed out."), _ => ("InternalError", 500, "Request failed; use the correlation ID for diagnosis.") }; + logger.LogError(e, "HTTP {Method} {Path} failed with {StatusCode} {ErrorCode}; correlationId={CorrelationId}", + context.Request.Method, context.Request.Path, status, code, correlationId); context.Response.StatusCode = status; await context.Response.WriteAsJsonAsync(new { correlationId, error = new { code, message, stage = "request", retry = false } }); } + finally + { + logger.LogInformation("HTTP {Method} {Path} -> {StatusCode}; correlationId={CorrelationId}; elapsedMs={ElapsedMs}", + context.Request.Method, context.Request.Path, context.Response.StatusCode, correlationId, stopwatch.ElapsedMilliseconds); + } }); app.UseDefaultFiles(); app.UseStaticFiles(); @@ -105,6 +132,8 @@ public static class ServiceHost return Results.Ok(await service.UploadAsync(form.Files[0], ct)); }); app.MapGet("/api/v1/files/{id}", (string id, AgentService service) => Results.File(service.Download(id), "application/octet-stream")); + app.MapPost("/api/v1/operations", (OperationSubmitRequest request, AgentService service, CancellationToken ct) => service.SubmitOperation(request, ct)); + app.MapGet("/api/v1/operations", (string? accountId, int? limit, int? offset, AgentService service) => service.Operations(accountId, limit ?? 50, offset ?? 0)); app.MapGet("/api/v1/operations/{id}", (string id, AgentService service) => service.Operation(id)); app.MapPost("/api/v1/operations/{id}/cancel", (string id, AgentService service) => service.CancelOperation(id)); app.MapMcp("/mcp"); diff --git a/src/WxAgent.Service/ServiceOptions.cs b/src/WxAgent.Service/ServiceOptions.cs index 4f55ff8..271ab26 100644 --- a/src/WxAgent.Service/ServiceOptions.cs +++ b/src/WxAgent.Service/ServiceOptions.cs @@ -1,6 +1,7 @@ using System.Net; using System.Security.Cryptography; using System.Text; +using System.Text.Json.Serialization; namespace WxAgent.Service; @@ -8,14 +9,25 @@ public sealed record ServiceCredential(string PrincipalId, string TokenSha256, s public sealed class ServiceOptions { + private static readonly System.Text.Json.JsonSerializerOptions CredentialJson = new(System.Text.Json.JsonSerializerDefaults.Web); + public string ListenUrl { get; init; } = "http://127.0.0.1:5088"; public bool AllowExternal { get; init; } public string[] AllowedHosts { get; init; } = ["127.0.0.1:5088", "localhost:5088", "[::1]:5088"]; public string[] AllowedOrigins { get; init; } = ["http://127.0.0.1:5088", "http://localhost:5088", "http://[::1]:5088"]; + public string? AccessToken { get; init; } public required string CredentialFile { get; init; } public required string DataDirectory { get; init; } + + // Kept only so older service.json files can be loaded and rewritten by the tray. + [JsonIgnore] + [Obsolete("Internal compatibility field; the value is ignored.")] public int QueueCapacity { get; init; } = 100; + [JsonIgnore] + [Obsolete("Internal compatibility field; the value is ignored.")] public string ListenerSession { get; init; } = "文件传输助手"; + [JsonIgnore] + [Obsolete("Internal compatibility field; the value is ignored.")] public bool EnableListenerEvents { get; init; } public void Validate() @@ -26,27 +38,34 @@ public sealed class ServiceOptions throw new ArgumentException("ListenUrl must be an explicit HTTP IP address and port."); if (!IPAddress.IsLoopback(address) && !AllowExternal) throw new ArgumentException("External HTTP requires AllowExternal; use a trusted isolated network."); - if (QueueCapacity is < 1 or > 100) throw new ArgumentOutOfRangeException(nameof(QueueCapacity)); - if (string.IsNullOrWhiteSpace(ListenerSession) || ListenerSession.Length > 200) throw new ArgumentException("ListenerSession must be a bounded nonempty name."); + if (AllowExternal && (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))) + throw new ArgumentException("External HTTP requires a concrete listen IP; do not use 0.0.0.0 or ::."); + if (AccessToken is not null && !IsValidAccessToken(AccessToken)) + throw new ArgumentException("AccessToken must be non-empty and contain no whitespace."); _ = ReadCredentials(); } // Reread on every authorization boundary: rotation has no overlap or stale cache. public ServiceCredential[] ReadCredentials() { - var credentials = System.Text.Json.JsonSerializer.Deserialize(File.ReadAllText(CredentialFile)) + var credentials = System.Text.Json.JsonSerializer.Deserialize(File.ReadAllText(CredentialFile), CredentialJson) ?? throw new InvalidDataException("No credentials configured."); - if (credentials.Length is < 1 or > 100 || credentials.Any(c => + if (credentials.Length != 1) + throw new InvalidDataException("Exactly one credential must be configured."); + if (credentials.Any(c => c is null || string.IsNullOrWhiteSpace(c.PrincipalId) || c.TokenSha256 is null || c.TokenSha256.Length != 64 || !c.TokenSha256.All(Uri.IsHexDigit) || c.Permissions is null || c.AccountIds is null || c.Permissions.Any(p => p is not ("read" or "content" or "write" or "manage" or "local-admin")) || - c.AccountIds.Any(string.IsNullOrWhiteSpace)) || - credentials.Select(c => c.PrincipalId).Distinct(StringComparer.Ordinal).Count() != credentials.Length || - credentials.Select(c => c.TokenSha256.ToUpperInvariant()).Distinct().Count() != credentials.Length) + c.AccountIds.Any(string.IsNullOrWhiteSpace))) throw new InvalidDataException("Invalid credential configuration."); return credentials; } + public static bool IsValidAccessToken(string? token) => + !string.IsNullOrEmpty(token) && token.All(c => !char.IsWhiteSpace(c)); + + public static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + public static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); } diff --git a/src/WxAgent.Service/ServiceSecurity.cs b/src/WxAgent.Service/ServiceSecurity.cs index e7f49a5..f5953ef 100644 --- a/src/WxAgent.Service/ServiceSecurity.cs +++ b/src/WxAgent.Service/ServiceSecurity.cs @@ -17,7 +17,7 @@ public sealed class ServiceSecurity(ServiceOptions options) public ServiceIdentity? AuthenticateToken(string token) { - if (token.Length is < 43 or > 256) return null; + if (!ServiceOptions.IsValidAccessToken(token)) return null; var hash = ServiceOptions.HashToken(token); return ReadCredentialsSafely().Where(c => EqualHash(c.TokenSha256, hash)) .Select(c => new ServiceIdentity(c.PrincipalId, c.TokenSha256, c.Permissions, c.AccountIds)).SingleOrDefault(); @@ -29,8 +29,6 @@ public sealed class ServiceSecurity(ServiceOptions options) { if (permission is not null && !original.Allows(permission)) throw new ServiceException("Forbidden", 403, "Permission required."); - if (accountId is not null && !original.AllowsAccount(accountId)) - throw new ServiceException("Forbidden", 403, "Account access denied."); return original; } var credential = ReadCredentialsSafely().SingleOrDefault(c => c.PrincipalId == original.PrincipalId && @@ -39,8 +37,8 @@ public sealed class ServiceSecurity(ServiceOptions options) var current = new ServiceIdentity(credential.PrincipalId, credential.TokenSha256, credential.Permissions, credential.AccountIds); if (permission is not null && !current.Allows(permission)) throw new ServiceException("Forbidden", 403, "Permission required."); - if (accountId is not null && !current.AllowsAccount(accountId)) - throw new ServiceException("Forbidden", 403, "Account access denied."); + // AccountIds is retained for credential-file compatibility. Account-level business isolation belongs to callers; + // the service still validates the selected account/window/target at the backend boundary. return current; } @@ -56,7 +54,11 @@ public sealed class ServiceSecurity(ServiceOptions options) public void ValidateSource(HttpContext context) { - // Host/Origin filtering is intentionally disabled; authentication and the optional loopback-only mode are the boundary. + if (!IsAllowedHost(context.Request.Host.Value)) + throw new ServiceException("UntrustedHost", 403, "Request host is not trusted."); + var origin = context.Request.Headers.Origin.ToString(); + if (!string.IsNullOrWhiteSpace(origin) && !IsAllowedOrigin(origin)) + throw new ServiceException("UntrustedOrigin", 403, "Request origin is not trusted."); } public static void RequireLocal(HttpContext context) @@ -74,10 +76,17 @@ public sealed class ServiceSecurity(ServiceOptions options) return identity; throw new ServiceException("Unauthorized", 401, "Valid Bearer credential required."); } + var isMutation = !HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method); if (!context.Request.Cookies.TryGetValue(CookieName, out var id) || !sessions.TryGetValue(id, out var session)) { - if (IsLoopback(context)) return LocalIdentity; - throw new ServiceException("Unauthorized", 401, "Login required."); + if (!IsLoopback(context)) throw new ServiceException("Unauthorized", 401, "Login required."); + if (isMutation) + { + RequireTrustedOrigin(context, required: true); + if (context.Request.Headers["X-WxAgent-Local"].ToString() != "1") + throw new ServiceException("LocalRequestHeaderRequired", 403, "Browser writes require the local request header."); + } + return LocalIdentity; } if (session.Identity.LocalOnly && !IsLoopback(context)) throw new ServiceException("Unauthorized", 401, "Local session cannot be used remotely."); @@ -87,14 +96,18 @@ public sealed class ServiceSecurity(ServiceOptions options) throw new ServiceException("Unauthorized", 401, "Session expired."); } var current = RequireCurrent(session.Identity); - if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) && - context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf) - throw new ServiceException("CsrfRejected", 403, "CSRF token required."); + if (isMutation) + { + RequireTrustedOrigin(context, required: true); + if (context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf) + throw new ServiceException("CsrfRejected", 403, "CSRF token required."); + } return current; } public object Login(HttpContext context, string token) { + RequireTrustedOrigin(context, required: true); // ponytail: process-wide login throttle; per-IP quotas only if legitimate shared use needs them. lock (loginGate) { @@ -114,6 +127,39 @@ public sealed class ServiceSecurity(ServiceOptions options) return new { identity.PrincipalId, identity.Permissions, identity.AccountIds, csrfToken = csrf, expires }; } + private void RequireTrustedOrigin(HttpContext context, bool required) + { + var origin = context.Request.Headers.Origin.ToString(); + if (string.IsNullOrWhiteSpace(origin)) + { + if (required) throw new ServiceException("OriginRequired", 403, "A trusted Origin is required for browser writes."); + return; + } + if (!IsAllowedOrigin(origin)) throw new ServiceException("UntrustedOrigin", 403, "Request origin is not trusted."); + } + + private bool IsAllowedHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) return false; + if (options.AllowedHosts.Any(value => string.Equals(value, host, StringComparison.OrdinalIgnoreCase))) return true; + if (!Uri.TryCreate(options.ListenUrl, UriKind.Absolute, out var listen) || + !IPAddress.TryParse(listen.Host.Trim('[', ']'), out var address) || IsAnyAddress(address)) return false; + return string.Equals(listen.Authority, host, StringComparison.OrdinalIgnoreCase); + } + + private bool IsAllowedOrigin(string origin) + { + if (!Uri.TryCreate(origin, UriKind.Absolute, out var parsed) || parsed.Scheme != "http" || + parsed.UserInfo.Length != 0 || parsed.AbsolutePath != "/" || parsed.Query.Length != 0 || parsed.Fragment.Length != 0) + return false; + var normalized = origin.TrimEnd('/'); + if (options.AllowedOrigins.Any(value => string.Equals(value.TrimEnd('/'), normalized, StringComparison.OrdinalIgnoreCase))) return true; + return Uri.TryCreate(options.ListenUrl, UriKind.Absolute, out var listen) && + string.Equals(listen.GetLeftPart(UriPartial.Authority), normalized, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsAnyAddress(IPAddress address) => address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any); + private static bool IsLoopback(HttpContext context) => context.Connection.RemoteIpAddress is { } address && IPAddress.IsLoopback(address); diff --git a/src/WxAgent.Service/wwwroot/app.js b/src/WxAgent.Service/wwwroot/app.js index 1441118..025b555 100644 --- a/src/WxAgent.Service/wwwroot/app.js +++ b/src/WxAgent.Service/wwwroot/app.js @@ -1,62 +1,92 @@ -const $ = id => document.getElementById(id); -let csrf = ''; +const $ = (id) => document.querySelector(`#${id}`); +let csrf = ""; let refreshTimer = 0; let busy = false; +let activeView = localStorage.getItem("wxagent-view") || "messages"; +let capabilityMap = new Map(); + +const operationStates = { + Queued: "排队中", + Running: "执行中", + Succeeded: "已完成", + Failed: "失败", + Cancelled: "已取消", + Unconfirmed: "待核对", +}; function showError(error) { - $('error').textContent = error?.message || '请求失败'; + const code = error?.code ? `${error.code}:` : ""; + $("error").textContent = `${code}${error?.message || "请求失败"}`; } function clearError() { - $('error').textContent = ''; + $("error").textContent = ""; } async function api(path, init = {}) { const response = await fetch(path, { ...init, - headers: { Accept: 'application/json', ...(init.headers || {}) } + credentials: "same-origin", + headers: { Accept: "application/json", ...init.headers }, }); if (!response.ok) { let body = {}; - try { body = await response.json(); } catch { /* response may not be JSON */ } - if (response.status === 401) { - $('app').hidden = true; - $('login').hidden = false; - $('connectionBadge').textContent = '未登录'; - $('connectionBadge').className = 'badge neutral'; + try { + body = await response.json(); + } catch { + /* response may not be JSON */ } - throw new Error(body.error?.message || `HTTP ${response.status}`); + if (response.status === 401) { + $("app").hidden = true; + $("login").hidden = false; + $("connectionBadge").textContent = "未登录"; + $("connectionBadge").className = "badge neutral"; + } + const error = new Error(body.error?.message || `HTTP ${response.status}`); + error.code = body.error?.code; + error.correlationId = + response.headers.get("X-Correlation-Id") || body.correlationId; + throw error; } return response.status === 204 ? null : response.json(); } async function get(path) { - try { return { ok: true, value: await api(path) }; } - catch (error) { return { ok: false, error }; } + try { + return { ok: true, value: await api(path) }; + } catch (error) { + return { ok: false, error }; + } } async function mutate(path, body) { return api(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, - body: JSON.stringify(body) + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrf, + "X-WxAgent-Local": "1", + }, + body: JSON.stringify(body), }); } function setList(id, items, format) { const target = $(id); - target.replaceChildren(...items.map(item => { - const row = document.createElement('div'); - row.className = 'row'; - row.textContent = format(item); - return row; - })); + target.replaceChildren( + ...items.map((item) => { + const row = document.createElement("div"); + row.className = "row"; + row.textContent = format(item); + return row; + }), + ); } function setListMessage(id, message, error = false) { const target = $(id); - const row = document.createElement('div'); - row.className = error ? 'row error-row' : 'row'; + const row = document.createElement("div"); + row.className = error ? "row error-row" : "row"; row.textContent = message; target.replaceChildren(row); } @@ -67,104 +97,163 @@ function value(result, fallback) { function friendlyAccount(account) { const binding = account.binding; - const identity = binding?.nickname || binding?.wechatId || account.displayName; - return identity ? `${identity} · ${account.bindingStatus || 'Unknown'}` : `数据库账号 ${account.accountId.slice(0, 10)}… · ${account.bindingStatus || 'Unknown'}`; + const identity = + binding?.nickname || binding?.wechatId || account.displayName; + return identity + ? `${identity} · ${account.bindingStatus || "Unknown"}` + : `数据库账号 ${String(account.accountId).slice(0, 10)}… · ${account.bindingStatus || "Unknown"}`; } function renderStatus(result) { if (!result.ok) { - $('connectionBadge').textContent = '状态读取失败'; - $('connectionBadge').className = 'badge error'; + $("connectionBadge").textContent = "状态读取失败"; + $("connectionBadge").className = "badge error"; return; } const status = result.value; - $('status').textContent = JSON.stringify(status, null, 2); - $('serviceState').textContent = status.serviceOnline ? '在线' : '离线'; - $('wechatState').textContent = status.wechatAvailable && status.sessionAvailable ? '可用' : '不可用'; - $('bindingState').textContent = status.activeAccountBound ? '已绑定' : '未绑定'; - $('modeState').textContent = status.defaultReadOnly ? '只读' : '可写'; - const healthy = status.serviceOnline && status.wechatAvailable && status.sessionAvailable; - $('connectionBadge').textContent = healthy ? '已连接' : '需检查'; - $('connectionBadge').className = `badge ${healthy ? 'ok' : 'warn'}`; + $("status").textContent = JSON.stringify(status, null, 2); + $("serviceState").textContent = status.serviceOnline ? "在线" : "离线"; + $("wechatState").textContent = + status.wechatAvailable && status.sessionAvailable ? "可用" : "不可用"; + $("bindingState").textContent = status.activeAccountBound + ? "已绑定" + : "未绑定"; + $("modeState").textContent = status.defaultReadOnly ? "只读" : "可写"; + const healthy = + status.serviceOnline && status.wechatAvailable && status.sessionAvailable; + $("connectionBadge").textContent = healthy ? "已连接" : "需检查"; + $("connectionBadge").className = `badge ${healthy ? "ok" : "warn"}`; } function renderCapabilities(result) { - if (!result.ok) return setListMessage('capabilities', result.error.message, true); - setList('capabilities', result.value, item => `${item.operation} — ${item.enabled ? '可用' : '禁用'}${item.disabledReason ? `:${item.disabledReason}` : ''}`); + if (!result.ok) { + capabilityMap = new Map(); + updateReplyAvailability(); + return setListMessage("capabilities", result.error.message, true); + } + capabilityMap = new Map(result.value.map((item) => [item.operation, item])); + setList( + "capabilities", + result.value, + (item) => + `${item.operation} — ${item.enabled ? "可用" : "禁用"}${item.disabledReason ? `:${item.disabledReason}` : ""}`, + ); + updateReplyAvailability(); } function renderBindings(accounts, targets) { - const accountRows = accounts.map(account => { - const row = document.createElement('div'); - row.className = 'row'; - const main = document.createElement('div'); - main.className = 'row-main'; - const title = document.createElement('div'); - title.className = 'row-title'; + const accountRows = accounts.map((account) => { + const row = document.createElement("div"); + row.className = "row"; + const main = document.createElement("div"); + main.className = "row-main"; + const title = document.createElement("div"); + title.className = "row-title"; title.textContent = friendlyAccount(account); - const meta = document.createElement('div'); - meta.className = 'row-meta'; + const meta = document.createElement("div"); + meta.className = "row-meta"; meta.textContent = account.binding - ? `${account.binding.wechatId || '微信号未读取'} · PID ${account.binding.processId} · HWND ${account.binding.windowHandle}` + ? `${account.binding.wechatId || "微信号未读取"} · PID ${account.binding.processId} · HWND ${account.binding.windowHandle}` : `Fingerprint: ${account.accountId}`; main.append(title, meta); row.append(main); if (account.binding) { - const unbind = document.createElement('button'); - unbind.className = 'secondary'; - unbind.textContent = '解绑'; + const unbind = document.createElement("button"); + unbind.className = "secondary"; + unbind.textContent = "解绑"; unbind.onclick = async () => { unbind.disabled = true; - try { await mutate('/api/v1/accounts/unbind', { accountId: account.accountId }); await refresh(); } - catch (error) { showError(error); unbind.disabled = false; } + try { + await mutate("/api/v1/accounts/unbind", { + accountId: account.accountId, + }); + await refresh(); + } catch (error) { + showError(error); + unbind.disabled = false; + } }; row.append(unbind); } return row; }); - $('accounts').replaceChildren(...accountRows); - if (!accountRows.length) setListMessage('accounts', '暂无数据库账号。'); + $("accounts").replaceChildren(...accountRows); + if (!accountRows.length) setListMessage("accounts", "暂无数据库账号。"); - const targetRows = targets.map(target => { - const row = document.createElement('div'); - row.className = 'row'; - const main = document.createElement('div'); - main.className = 'row-main'; - const title = document.createElement('div'); - title.className = 'row-title'; - title.textContent = target.wechatId || target.nickname || '身份未读取'; - const meta = document.createElement('div'); - meta.className = 'row-meta'; - meta.textContent = `${target.targetId} · PID ${target.processId} · HWND ${target.windowHandle}${target.isBound ? ' · 已绑定' : ''}`; + const targetRows = targets.map((target) => { + const row = document.createElement("div"); + row.className = "row"; + const main = document.createElement("div"); + main.className = "row-main"; + const title = document.createElement("div"); + title.className = "row-title"; + title.textContent = target.wechatId || target.nickname || "身份未读取"; + const meta = document.createElement("div"); + meta.className = "row-meta"; + meta.textContent = `${target.targetId} · PID ${target.processId} · HWND ${target.windowHandle}${target.isBound ? " · 已绑定" : ""}`; main.append(title, meta); row.append(main); if (!target.isBound) { - const select = document.createElement('select'); - select.setAttribute('aria-label', `为 ${target.targetId} 选择数据库账号`); - select.append(new Option('选择账号', ''), ...accounts.filter(account => !account.binding).map(account => new Option(friendlyAccount(account), account.accountId))); - const bind = document.createElement('button'); - bind.textContent = '绑定'; + const select = document.createElement("select"); + select.setAttribute("aria-label", `为 ${target.targetId} 选择数据库账号`); + select.append( + new Option("选择账号", ""), + ...accounts + .filter((account) => !account.binding) + .map( + (account) => + new Option(friendlyAccount(account), account.accountId), + ), + ); + const bind = document.createElement("button"); + bind.textContent = "绑定"; bind.onclick = async () => { - if (!select.value) { showError(new Error('请先选择数据库账号')); return; } + if (!select.value) { + showError(new Error("请先选择数据库账号")); + return; + } bind.disabled = true; - try { await mutate('/api/v1/accounts/bind', { accountId: select.value, targetId: target.targetId }); await refresh(); } - catch (error) { showError(error); bind.disabled = false; } + try { + await mutate("/api/v1/accounts/bind", { + accountId: select.value, + targetId: target.targetId, + }); + await refresh(); + } catch (error) { + showError(error); + bind.disabled = false; + } }; row.append(select, bind); } return row; }); - $('uiTargets').replaceChildren(...targetRows); - if (!targetRows.length) setListMessage('uiTargets', '未发现当前交互式会话中的微信主窗口。'); + $("uiTargets").replaceChildren(...targetRows); + if (!targetRows.length) + setListMessage("uiTargets", "未发现当前交互式会话中的微信主窗口。"); } function populateAccounts(result) { - if (!result.ok) return setListMessage('accounts', result.error.message, true); + if (!result.ok) { + setListMessage("accounts", result.error.message, true); + return []; + } const accounts = result.value.items || []; - const select = $('accountSelect'); + const select = $("accountSelect"); const previous = select.value; - select.replaceChildren(new Option('选择已绑定账号', ''), ...accounts.filter(account => account.binding).map(account => new Option(friendlyAccount(account), account.accountId))); - select.value = accounts.some(account => account.accountId === previous && account.binding) ? previous : ''; + select.replaceChildren( + new Option("选择已绑定账号", ""), + ...accounts + .filter((account) => account.binding) + .map( + (account) => new Option(friendlyAccount(account), account.accountId), + ), + ); + select.value = accounts.some( + (account) => account.accountId === previous && account.binding, + ) + ? previous + : ""; return accounts; } @@ -175,88 +264,306 @@ function renderScoped(result, id, format, emptyMessage) { if (!items.length) setListMessage(id, emptyMessage); } +function renderReplyTargets(result) { + const sessions = result.ok ? result.value.items || [] : []; + const select = $("replyTarget"); + const previous = select.value; + select.replaceChildren( + new Option("选择会话", ""), + ...sessions.map( + (session) => new Option(session.name, session.automationId), + ), + ); + select.value = sessions.some((session) => session.automationId === previous) + ? previous + : ""; + updateReplyAvailability(); +} + +function updateReplyAvailability() { + const capability = capabilityMap.get("send-text"); + const hasAccount = Boolean($("accountSelect")?.value); + const hasTarget = Boolean($("replyTarget")?.value); + const enabled = Boolean(capability?.enabled && hasAccount && hasTarget); + $("replyTarget").disabled = !capability?.enabled; + $("replyText").disabled = !capability?.enabled; + $("sendReply").disabled = !enabled; + $("replyCapability").textContent = capability?.enabled + ? "发送前会再次校验账号和目标" + : capability?.disabledReason || "写能力尚未开放。"; + if (capability?.enabled) { + $("replyState").textContent = hasTarget + ? "点击发送后创建任务。" + : "请选择目标会话。"; + } else { + $("replyState").textContent = "当前不会发送消息。"; + } +} + +async function sendReply() { + const accountId = $("accountSelect").value; + const targetId = $("replyTarget").value; + const text = $("replyText").value; + if (!accountId || !targetId || !text.trim()) { + showError(new Error("请选择账号、目标会话并输入文本。")); + return; + } + const button = $("sendReply"); + button.disabled = true; + clearError(); + try { + await api("/api/v1/operations", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrf, + "X-WxAgent-Local": "1", + }, + body: JSON.stringify({ + kind: "send-text", + accountId, + targetId, + text, + idempotencyKey: crypto.randomUUID(), + confirmed: true, + }), + }); + $("replyText").value = ""; + setView("operations"); + await refresh(); + } catch (error) { + showError(error); + } finally { + updateReplyAvailability(); + } +} + +function operationState(state) { + return operationStates[state] || state || "未知"; +} + +function renderOperations(result) { + if (!result.ok) + return setListMessage("operations", result.error.message, true); + const items = result.value.items || []; + const target = $("operations"); + target.replaceChildren( + ...items.map((item) => { + const row = document.createElement("div"); + row.className = "row task-row"; + const main = document.createElement("div"); + main.className = "row-main"; + const title = document.createElement("div"); + title.className = "row-title"; + title.textContent = `${item.capability} · ${operationState(item.state)}`; + const meta = document.createElement("div"); + meta.className = "row-meta"; + const created = item.createdAt + ? new Date(item.createdAt).toLocaleString() + : "时间未知"; + meta.textContent = `${created} · ${item.stage || "未开始"} · 账号 ${String(item.accountId || "").slice(0, 10)}…`; + main.append(title, meta); + const actions = document.createElement("div"); + actions.className = "row-actions"; + const detail = document.createElement("button"); + detail.className = "secondary"; + detail.textContent = "详情"; + detail.onclick = async () => { + detail.disabled = true; + try { + $("operation").textContent = JSON.stringify( + await api(`/api/v1/operations/${encodeURIComponent(item.id)}`), + null, + 2, + ); + } catch (error) { + showError(error); + } finally { + detail.disabled = false; + } + }; + actions.append(detail); + if (item.state === "Queued" || item.state === "Running") { + const stop = document.createElement("button"); + stop.className = "secondary"; + stop.textContent = "停止"; + stop.onclick = async () => { + stop.disabled = true; + try { + await api( + `/api/v1/operations/${encodeURIComponent(item.id)}/cancel`, + { + method: "POST", + headers: { "X-CSRF-Token": csrf, "X-WxAgent-Local": "1" }, + }, + ); + await refresh(); + } catch (error) { + showError(error); + stop.disabled = false; + } + }; + actions.append(stop); + } + row.append(main, actions); + return row; + }), + ); + if (!items.length) setListMessage("operations", "暂无任务。"); +} + +function setView(view) { + activeView = view; + localStorage.setItem("wxagent-view", view); + document + .querySelectorAll("[data-view]") + .forEach((button) => + button.classList.toggle("active", button.dataset.view === view), + ); + document.querySelectorAll("[data-view-panel]").forEach((panel) => { + panel.hidden = panel.dataset.viewPanel !== view; + }); +} + async function refresh() { if (busy) return; busy = true; clearError(); - $('refresh').disabled = true; - $('refresh').textContent = '刷新中…'; + $("refresh").disabled = true; + $("refresh").textContent = "刷新中…"; try { - const [status, capabilities, accounts, targets] = await Promise.all([ - get('/api/v1/status'), get('/api/v1/capabilities'), get('/api/v1/accounts?limit=50'), get('/api/v1/ui-targets') - ]); + const [status, capabilities, accounts, targets, operations] = + await Promise.all([ + get("/api/v1/status"), + get("/api/v1/capabilities"), + get("/api/v1/accounts?limit=50"), + get("/api/v1/ui-targets"), + get("/api/v1/operations?limit=50"), + ]); renderStatus(status); renderCapabilities(capabilities); - const accountItems = populateAccounts(accounts) || []; + renderOperations(operations); + const accountItems = populateAccounts(accounts); renderBindings(accountItems, value(targets, [])); - const accountId = $('accountSelect').value; + const accountId = $("accountSelect").value; if (!accountId) { - const message = '选择已绑定账号后加载数据。'; - setListMessage('sessions', message); setListMessage('contacts', message); setListMessage('messages', message); + const message = "在“连接与设置”确认账号后加载数据。"; + setListMessage("sessions", message); + setListMessage("contacts", message); + setListMessage("messages", message); + renderReplyTargets({ ok: true, value: { items: [] } }); return; } const query = `&accountId=${encodeURIComponent(accountId)}`; const [sessions, contacts, messages] = await Promise.all([ get(`/api/v1/sessions?limit=50${query}`), get(`/api/v1/contacts?limit=50${query}`), - get(`/api/v1/messages?limit=50&includeContent=${$('content').checked}${query}`) + get( + `/api/v1/messages?limit=50&includeContent=${$("content").checked}${query}`, + ), ]); - renderScoped(sessions, 'sessions', item => `${item.name}${item.isCurrent ? '(当前)' : ''}`, '暂无可见会话。'); - renderScoped(contacts, 'contacts', item => `${item.displayName || '[未知]'} — ${item.id}`, '暂无联系人数据或当前凭据未授权。'); - renderScoped(messages, 'messages', item => `${item.type} ${item.sender || ''}: ${item.content ?? item.summary ?? '[正文未授权]'}`, '暂无可见消息。'); + renderScoped( + sessions, + "sessions", + (item) => `${item.name}${item.isCurrent ? "(当前)" : ""}`, + "暂无可见会话。", + ); + renderReplyTargets(sessions); + renderScoped( + contacts, + "contacts", + (item) => `${item.displayName || "[未知]"} — ${item.id}`, + "暂无联系人数据或当前凭据尚未开放。", + ); + renderScoped( + messages, + "messages", + (item) => + `${item.type} ${item.sender || ""}: ${item.content ?? item.summary ?? "[正文未授权]"}`, + "暂无可见消息。", + ); } finally { - $('refresh').disabled = false; - $('refresh').textContent = '刷新'; - $('lastUpdated').textContent = `更新于 ${new Date().toLocaleTimeString()}`; + $("refresh").disabled = false; + $("refresh").textContent = "刷新"; + $("lastUpdated").textContent = `更新于 ${new Date().toLocaleTimeString()}`; busy = false; } } function setAutoRefresh(enabled) { if (refreshTimer) window.clearInterval(refreshTimer); - refreshTimer = enabled ? window.setInterval(() => { if (!document.hidden) refresh(); }, 15000) : 0; - localStorage.setItem('wxagent-auto-refresh', enabled ? '1' : '0'); + refreshTimer = enabled + ? window.setInterval(() => { + if (!document.hidden) refresh(); + }, 15000) + : 0; + localStorage.setItem("wxagent-auto-refresh", enabled ? "1" : "0"); } -$('loginForm').addEventListener('submit', async event => { +$("loginForm").addEventListener("submit", async (event) => { event.preventDefault(); - const button = $('loginButton'); + const button = $("loginButton"); button.disabled = true; - button.textContent = '登录中…'; + button.textContent = "连接中…"; clearError(); try { - const response = await api('/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: $('token').value }) }); + const response = await api("/api/v1/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: $("token").value }), + }); csrf = response.csrfToken; - $('token').value = ''; - $('login').hidden = true; - $('app').hidden = false; + $("token").value = ""; + $("login").hidden = true; + $("app").hidden = false; await refresh(); - } catch (error) { showError(error); } - finally { button.disabled = false; button.textContent = '登录'; } + } catch (error) { + showError(error); + } finally { + button.disabled = false; + button.textContent = "连接"; + } }); -$('refresh').onclick = refresh; -$('accountSelect').onchange = refresh; -$('content').onchange = refresh; -$('autoRefresh').checked = localStorage.getItem('wxagent-auto-refresh') === '1'; -$('autoRefresh').onchange = event => setAutoRefresh(event.target.checked); -if ($('autoRefresh').checked) setAutoRefresh(true); -$('lookup').onclick = async () => { - if (!$('operationId').value.trim()) { showError(new Error('请输入 Operation ID')); return; } - try { $('operation').textContent = JSON.stringify(await api(`/api/v1/operations/${encodeURIComponent($('operationId').value.trim())}`), null, 2); } - catch (error) { showError(error); } -}; -$('logout').onclick = async () => { - try { await api('/api/v1/logout', { method: 'POST', headers: { 'X-CSRF-Token': csrf } }); } - finally { if (refreshTimer) window.clearInterval(refreshTimer); $('app').hidden = true; $('login').hidden = false; $('connectionBadge').textContent = '未连接'; $('connectionBadge').className = 'badge neutral'; } +$("refresh").onclick = refresh; +$("accountSelect").onchange = refresh; +$("content").onchange = refresh; +$("replyTarget").onchange = updateReplyAvailability; +$("replyText").oninput = updateReplyAvailability; +$("sendReply").onclick = sendReply; +$("autoRefresh").checked = localStorage.getItem("wxagent-auto-refresh") === "1"; +$("autoRefresh").onchange = (event) => setAutoRefresh(event.target.checked); +document.querySelectorAll("[data-view]").forEach((button) => { + button.onclick = () => setView(button.dataset.view); +}); +setView( + document.querySelector(`[data-view="${activeView}"]`) + ? activeView + : "messages", +); +if ($("autoRefresh").checked) setAutoRefresh(true); +$("logout").onclick = async () => { + try { + await api("/api/v1/logout", { + method: "POST", + headers: { "X-CSRF-Token": csrf, "X-WxAgent-Local": "1" }, + }); + } finally { + if (refreshTimer) window.clearInterval(refreshTimer); + csrf = ""; + $("app").hidden = true; + $("login").hidden = false; + $("connectionBadge").textContent = "未连接"; + $("connectionBadge").className = "badge neutral"; + } }; (async () => { - const local = await get('/api/v1/status'); + const local = await get("/api/v1/status"); if (local.ok) { - $('login').hidden = true; - $('app').hidden = false; + $("login").hidden = true; + $("app").hidden = false; await refresh(); } })(); diff --git a/src/WxAgent.Service/wwwroot/index.html b/src/WxAgent.Service/wwwroot/index.html index d14e235..c4f3c87 100644 --- a/src/WxAgent.Service/wwwroot/index.html +++ b/src/WxAgent.Service/wwwroot/index.html @@ -3,23 +3,27 @@ - WxAgent 控制台 + WxAgent 工作台
-

DESKTOP AGENT

WxAgent 控制台

安全查看微信状态、账号绑定和只读数据。

+
+

DESKTOP AGENT

+

WxAgent 工作台

+

连接 Agent,查看消息、联系人和执行任务。

+
未连接

HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。

@@ -30,26 +34,54 @@ -
-
服务—
-
微信 UI—
-
当前绑定—
-
默认模式只读
-
+ -
诊断详情
-

能力

按当前凭据显示
-
-

账号绑定

数据库账号 ↔ 微信窗口
-

微信窗口

显式选择后绑定
-
+
+
+

消息

从已确认账号开始

先在“连接与设置”确认账号和微信窗口,再加载会话与消息。

+ +
+

会话

当前账号
+

回复

发送能力检查中…
写能力尚未开放。
+

可见消息

只显示当前可读取的数据
+
-

会话

需要选择已绑定账号
-

联系人 / 群

只读数据库数据
-

可见消息

-

任务查询

粘贴 Operation ID
+ + + + + + +
diff --git a/src/WxAgent.Service/wwwroot/styles.css b/src/WxAgent.Service/wwwroot/styles.css index 4deee62..015eca3 100644 --- a/src/WxAgent.Service/wwwroot/styles.css +++ b/src/WxAgent.Service/wwwroot/styles.css @@ -1 +1 @@ -:root{color-scheme:light;--bg:#f4f7fb;--panel:#fff;--ink:#182230;--muted:#64748b;--line:#dbe3ee;--blue:#155eef;--blue-dark:#0d47b5;--danger:#b42318;--success:#067647;--warning:#8a5b00}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:1240px;margin:auto;padding:28px 20px 60px}.hero{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.eyebrow{margin:0;color:var(--blue);font-size:12px;font-weight:800;letter-spacing:.12em}.hero h1{margin:2px 0 4px;font-size:30px;letter-spacing:-.02em}.subtitle{margin:0;color:var(--muted)}.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:13px;font-weight:700;white-space:nowrap}.badge:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}.badge.ok{color:var(--success);background:#ecfdf3}.badge.warn{color:var(--warning);background:#fffaeb}.badge.error{color:var(--danger);background:#fef3f2}.badge.neutral{color:var(--muted);background:#eef2f6}.warning{margin:0 0 18px;padding:11px 14px;color:var(--warning);background:#fffaeb;border:1px solid #f5d78e;border-radius:10px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 3px 12px #172b4d0b}.login-panel{max-width:620px;padding:24px}.app-shell{display:grid;gap:16px}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;position:sticky;top:10px;z-index:2}.toolbar-group{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.account-picker{min-width:310px}.account-picker label{margin:0;white-space:nowrap;font-weight:700}.input-row{display:flex;align-items:center;gap:10px}.input-row input{flex:1;min-width:0}.login-form label{display:block;margin:0 0 6px;font-weight:700}input,select{width:100%;min-height:40px;padding:9px 11px;border:1px solid #a8b5c5;border-radius:8px;background:#fff;color:var(--ink);font:inherit}button{min-height:40px;padding:8px 15px;border:1px solid var(--blue);border-radius:8px;background:var(--blue);color:#fff;font:inherit;font-weight:700;cursor:pointer;transition:background .15s,transform .15s}button:hover{background:var(--blue-dark)}button:active{transform:translateY(1px)}button:disabled{opacity:.55;cursor:wait}.secondary{border-color:var(--line);background:#fff;color:var(--ink)}.secondary:hover{background:#f8fafc}.toggle{display:inline-flex;align-items:center;gap:7px;margin:0;color:var(--ink);white-space:nowrap}.toggle input{width:16px;min-height:16px;margin:0}.muted{color:var(--muted);font-size:13px}.status-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric{padding:16px 18px}.metric span{display:block;color:var(--muted);font-size:13px}.metric strong{display:block;margin-top:3px;font-size:21px}.dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.panel:not(.toolbar){padding:18px}.section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px}.section-heading h2{margin:0;font-size:18px}.list{min-height:40px}.row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid #edf1f5;overflow-wrap:anywhere}.row:last-child{border-bottom:0}.row-main{flex:1;min-width:0}.row-title{font-weight:700}.row-meta{color:var(--muted);font-size:12px}.row button{min-height:34px;padding:5px 10px;font-size:13px}.row select{width:auto;min-width:170px;min-height:34px;padding:5px 8px;font-size:13px}.error{min-height:0;margin:0;color:var(--danger);font-weight:600}.error:not(:empty){padding:10px 12px;background:#fef3f2;border:1px solid #fecdca;border-radius:8px}.error-row{color:var(--danger);background:#fff8f7;border-radius:7px;padding:10px}.details summary{cursor:pointer;font-weight:700;color:#334155}.details pre{margin:12px 0 0}pre{white-space:pre-wrap;overflow:auto;background:#111827;color:#e5e7eb;padding:12px;border-radius:8px;font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}#operation:empty{display:none}@media(max-width:850px){main{padding:18px 12px 40px}.toolbar{position:static;align-items:flex-start;flex-direction:column}.account-picker{width:100%;min-width:0}.status-grid{grid-template-columns:repeat(2,1fr)}.dashboard-grid{grid-template-columns:1fr}}@media(max-width:520px){.hero{display:block}.hero .badge{margin-top:12px}.status-grid{grid-template-columns:1fr 1fr}.input-row{align-items:stretch}.input-row button{flex:none}.section-heading{align-items:flex-start;flex-direction:column}.row{align-items:flex-start;flex-wrap:wrap}.row select{flex:1;min-width:150px}} +:root{color-scheme:light;--bg:#f4f7fb;--panel:#fff;--ink:#182230;--muted:#64748b;--line:#dbe3ee;--blue:#155eef;--blue-dark:#0d47b5;--danger:#b42318;--success:#067647;--warning:#8a5b00}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:1240px;margin:auto;padding:28px 20px 60px}.hero{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.eyebrow{margin:0;color:var(--blue);font-size:12px;font-weight:800;letter-spacing:.12em}.hero h1{margin:2px 0 4px;font-size:30px;letter-spacing:-.02em}.subtitle{margin:0;color:var(--muted)}.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:13px;font-weight:700;white-space:nowrap}.badge:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}.badge.ok{color:var(--success);background:#ecfdf3}.badge.warn{color:var(--warning);background:#fffaeb}.badge.error{color:var(--danger);background:#fef3f2}.badge.neutral{color:var(--muted);background:#eef2f6}.warning{margin:0 0 18px;padding:11px 14px;color:var(--warning);background:#fffaeb;border:1px solid #f5d78e;border-radius:10px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 3px 12px #172b4d0b}.login-panel{max-width:620px;padding:24px}.app-shell{display:grid;gap:16px}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;position:sticky;top:10px;z-index:2}.toolbar-group{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.account-picker{min-width:310px}.account-picker label{margin:0;white-space:nowrap;font-weight:700}.input-row{display:flex;align-items:center;gap:10px}.input-row input{flex:1;min-width:0}.login-form label{display:block;margin:0 0 6px;font-weight:700}input,select{width:100%;min-height:40px;padding:9px 11px;border:1px solid #a8b5c5;border-radius:8px;background:#fff;color:var(--ink);font:inherit}button{min-height:40px;padding:8px 15px;border:1px solid var(--blue);border-radius:8px;background:var(--blue);color:#fff;font:inherit;font-weight:700;cursor:pointer;transition:background .15s,transform .15s}button:hover{background:var(--blue-dark)}button:active{transform:translateY(1px)}button:disabled{opacity:.55;cursor:wait}.secondary{border-color:var(--line);background:#fff;color:var(--ink)}.secondary:hover{background:#f8fafc}.toggle{display:inline-flex;align-items:center;gap:7px;margin:0;color:var(--ink);white-space:nowrap}.toggle input{width:16px;min-height:16px;margin:0}.muted{color:var(--muted);font-size:13px}.status-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric{padding:16px 18px}.metric span{display:block;color:var(--muted);font-size:13px}.metric strong{display:block;margin-top:3px;font-size:21px}.dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.panel:not(.toolbar){padding:18px}.section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px}.section-heading h2{margin:0;font-size:18px}.list{min-height:40px}.row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid #edf1f5;overflow-wrap:anywhere}.row:last-child{border-bottom:0}.row-main{flex:1;min-width:0}.row-title{font-weight:700}.row-meta{color:var(--muted);font-size:12px}.row button{min-height:34px;padding:5px 10px;font-size:13px}.row select{width:auto;min-width:170px;min-height:34px;padding:5px 8px;font-size:13px}.error{min-height:0;margin:0;color:var(--danger);font-weight:600}.error:not(:empty){padding:10px 12px;background:#fef3f2;border:1px solid #fecdca;border-radius:8px}.error-row{color:var(--danger);background:#fff8f7;border-radius:7px;padding:10px}.details summary{cursor:pointer;font-weight:700;color:#334155}.details pre{margin:12px 0 0}pre{white-space:pre-wrap;overflow:auto;background:#111827;color:#e5e7eb;padding:12px;border-radius:8px;font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}#operation:empty{display:none}.workbench-nav{display:flex;gap:6px;padding:7px;position:sticky;top:78px;z-index:1}.nav-button{border-color:transparent;background:transparent;color:var(--muted);flex:1}.nav-button:hover,.nav-button.active{background:#eaf1ff;color:var(--blue-dark)}.view-panel{display:grid;gap:16px}.intro-panel{display:flex;align-items:center;justify-content:space-between;gap:18px}.intro-panel h2{margin:2px 0 4px}.placeholder{min-height:230px;display:grid;align-content:center;justify-items:start;gap:8px}.placeholder h2{margin:0}.task-row .row-title{font-size:15px}.row-actions{display:flex;gap:6px;flex-wrap:wrap}.reply-form{display:grid;gap:8px;max-width:720px}.reply-form textarea{width:100%;resize:vertical;padding:10px 11px;border:1px solid #a8b5c5;border-radius:8px;background:#fff;color:var(--ink);font:inherit}.reply-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}@media(max-width:850px){main{padding:18px 12px 40px}.toolbar{position:static;align-items:flex-start;flex-direction:column}.workbench-nav{position:static;overflow:auto}.nav-button{flex:0 0 auto}.account-picker{width:100%;min-width:0}.status-grid{grid-template-columns:repeat(2,1fr)}.dashboard-grid{grid-template-columns:1fr}.intro-panel{align-items:flex-start;flex-direction:column}}@media(max-width:520px){.hero{display:block}.hero .badge{margin-top:12px}.status-grid{grid-template-columns:1fr 1fr}.input-row{align-items:stretch}.input-row button{flex:none}.section-heading{align-items:flex-start;flex-direction:column}.row{align-items:flex-start;flex-wrap:wrap}.row select{flex:1;min-width:150px}} diff --git a/src/WxAgent.Tray/Program.cs b/src/WxAgent.Tray/Program.cs index 63adf22..6bc61f1 100644 --- a/src/WxAgent.Tray/Program.cs +++ b/src/WxAgent.Tray/Program.cs @@ -1,10 +1,10 @@ using System.Diagnostics; using System.Net; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Windows.Forms; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Logging; using WxAgent.Host; using WxAgent.Service; @@ -89,11 +89,11 @@ internal sealed class TrayApplicationContext : ApplicationContext var status = new ToolStripMenuItem("正在启动…") { Enabled = false }; menu.Items.Add(status); menu.Items.Add(new ToolStripSeparator()); - menu.Items.Add(new ToolStripMenuItem("打开控制台", null, (_, _) => OpenConsole())); + menu.Items.Add(new ToolStripMenuItem("打开工作台", null, (_, _) => OpenConsole())); + menu.Items.Add(new ToolStripMenuItem("复制工作台地址", null, (_, _) => CopyConsoleAddress())); menu.Items.Add(serviceToggle); menu.Items.Add(reload); menu.Items.Add(new ToolStripMenuItem("服务设置...", null, (_, _) => OpenConfig())); - menu.Items.Add(new ToolStripMenuItem("访问凭据...", null, (_, _) => ConfigureCredentials())); menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(new ToolStripMenuItem("退出", null, async (_, _) => await ExitAsync())); @@ -107,11 +107,13 @@ internal sealed class TrayApplicationContext : ApplicationContext trayIcon.DoubleClick += (_, _) => OpenConsole(); try { - EnsureConfiguration(); + var firstRun = EnsureConfiguration(); StartServiceAsync().GetAwaiter().GetResult(); + if (firstRun) OpenConfig(); } catch (Exception exception) { + LogError("Tray startup failed.", exception); SetStatus($"启动失败: {exception.Message}"); trayIcon.ShowBalloonTip(5000, "WxAgent 启动失败", exception.Message, ToolTipIcon.Error); } @@ -119,31 +121,64 @@ internal sealed class TrayApplicationContext : ApplicationContext private ToolStripMenuItem StatusItem => (ToolStripMenuItem)trayIcon.ContextMenuStrip!.Items[0]; - private void EnsureConfiguration() + private bool EnsureConfiguration() { var directory = Path.GetDirectoryName(configPath)!; Directory.CreateDirectory(directory); if (!File.Exists(configPath)) { - WriteJson(configPath, new ServiceOptions + var initialToken = ServiceOptions.GenerateToken(); + var initial = new ServiceOptions { + AccessToken = initialToken, CredentialFile = Path.Combine(directory, "credentials.json"), DataDirectory = Path.Combine(directory, "data") - }); + }; + Directory.CreateDirectory(initial.DataDirectory); + WriteJson(configPath, initial); + WriteCredentials(initial.CredentialFile, initialToken); + options = initial; + return true; } var loaded = ReadOptions(); - options = loaded; - Directory.CreateDirectory(loaded.DataDirectory); - if (!File.Exists(loaded.CredentialFile)) - { - var token = CreateCredentials(loaded.CredentialFile); - ShowToken(token); - } + var credentialFile = ResolvePath(loaded.CredentialFile, directory, "credentials.json"); + var dataDirectory = ResolvePath(loaded.DataDirectory, directory, "data"); + var token = ServiceOptions.IsValidAccessToken(loaded.AccessToken) + ? loaded.AccessToken! + : ServiceOptions.GenerateToken(); + var normalized = WithConfiguration(loaded, token, credentialFile, dataDirectory); + var changed = !ServiceOptions.IsValidAccessToken(loaded.AccessToken) + || !string.Equals(loaded.CredentialFile, credentialFile, StringComparison.OrdinalIgnoreCase) + || !string.Equals(loaded.DataDirectory, dataDirectory, StringComparison.OrdinalIgnoreCase); + if (changed) WriteJson(configPath, normalized); + Directory.CreateDirectory(normalized.DataDirectory); + // Always rewrite one canonical record so old/malformed/multi-record files self-heal on tray startup. + WriteCredentials(normalized.CredentialFile, token); + options = normalized; + return changed; } + private static string ResolvePath(string? value, string baseDirectory, string fallback) + { + if (string.IsNullOrWhiteSpace(value)) return Path.Combine(baseDirectory, fallback); + try { return Path.GetFullPath(value, baseDirectory); } + catch (ArgumentException) { return Path.Combine(baseDirectory, fallback); } + } + + private static ServiceOptions WithConfiguration(ServiceOptions source, string token, string credentialFile, string dataDirectory) => new() + { + ListenUrl = source.ListenUrl, + AllowExternal = source.AllowExternal, + AllowedHosts = source.AllowedHosts ?? [], + AllowedOrigins = source.AllowedOrigins ?? [], + AccessToken = token, + CredentialFile = credentialFile, + DataDirectory = dataDirectory + }; + private ServiceOptions ReadOptions() => - JsonSerializer.Deserialize(File.ReadAllText(configPath), ServiceHost.Json) + JsonSerializer.Deserialize(File.ReadAllText(configPath), ServiceHost.ConfigurationJson) ?? throw new InvalidDataException("service.json is empty."); private async Task StartServiceAsync() @@ -152,7 +187,7 @@ internal sealed class TrayApplicationContext : ApplicationContext var loaded = ReadOptions(); loaded.Validate(); options = loaded; - service = ServiceHost.Build(loaded, new WindowsAgentBackend(loaded, new AccountBindingStore(loaded))); + service = ServiceHost.Build(loaded, new WindowsAgentBackend(new AccountBindingStore(loaded)), logPath: GetLogPath()); try { await service.StartAsync(); @@ -188,6 +223,7 @@ internal sealed class TrayApplicationContext : ApplicationContext } catch (Exception exception) { + LogError("Service toggle failed.", exception); SetStatus($"操作失败: {exception.Message}"); trayIcon.ShowBalloonTip(5000, "WxAgent", exception.Message, ToolTipIcon.Error); } @@ -204,15 +240,38 @@ internal sealed class TrayApplicationContext : ApplicationContext } catch (Exception exception) { + LogError("Configuration reload failed.", exception); SetStatus($"配置错误: {exception.Message}"); trayIcon.ShowBalloonTip(5000, "WxAgent 配置错误", exception.Message, ToolTipIcon.Error); } } + private string GetLogPath() => Path.Combine(Path.GetDirectoryName(configPath)!, "wxagent.log"); + + private void LogError(string message, Exception exception) => + RuntimeLog.Append(GetLogPath(), LogLevel.Error, "WxAgent.Tray", message, exception); + + private string GetConsoleUrl() + { + if (!Uri.TryCreate(options?.ListenUrl ?? "http://127.0.0.1:5088", UriKind.Absolute, out var uri)) + return "http://127.0.0.1:5088/"; + var host = uri.Host; + if (IPAddress.TryParse(host.Trim('[', ']'), out var address) && (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))) + host = "127.0.0.1"; + var formattedHost = host.Contains(':') ? $"[{host.Trim('[', ']')}]" : host; + return $"{uri.Scheme}://{formattedHost}:{uri.Port}/"; + } + private void OpenConsole() { - var url = (options?.ListenUrl.TrimEnd('/') ?? "http://127.0.0.1:5088") + "/"; - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + Process.Start(new ProcessStartInfo(GetConsoleUrl()) { UseShellExecute = true }); + } + + private void CopyConsoleAddress() + { + var url = GetConsoleUrl(); + Clipboard.SetText(url); + trayIcon.ShowBalloonTip(2000, "WxAgent", $"工作台地址已复制:{url}", ToolTipIcon.Info); } private void OpenConfig() @@ -221,29 +280,17 @@ internal sealed class TrayApplicationContext : ApplicationContext { EnsureConfiguration(); if (ServiceSettingsEditor.Show(options!) is not { } edited) return; + WriteCredentials(edited.CredentialFile, edited.AccessToken!); WriteJson(configPath, edited); _ = ReloadAsync(); } catch (Exception exception) { + LogError("Service settings save failed.", exception); trayIcon.ShowBalloonTip(5000, "WxAgent 服务设置失败", exception.Message, ToolTipIcon.Error); } } - private void ConfigureCredentials() - { - try - { - EnsureConfiguration(); - CredentialEditor.Show(options!.CredentialFile); - trayIcon.ShowBalloonTip(2500, "WxAgent", "访问凭据已更新。", ToolTipIcon.Info); - } - catch (Exception exception) - { - trayIcon.ShowBalloonTip(5000, "WxAgent 凭据配置失败", exception.Message, ToolTipIcon.Error); - } - } - private void SetStatus(string message) { StatusItem.Text = message.Length > 60 ? message[..60] : message; @@ -279,15 +326,14 @@ internal sealed class TrayApplicationContext : ApplicationContext base.Dispose(disposing); } - private static string CreateCredentials(string path) + private static void WriteCredentials(string path, string token) { - var bytes = RandomNumberGenerator.GetBytes(32); - var token = Convert.ToHexString(bytes).ToLowerInvariant(); - WriteJson(path, new[] + var temporary = path + ".tmp"; + WriteJson(temporary, new[] { new ServiceCredential("local-admin", ServiceOptions.HashToken(token), ["read", "content", "manage"], []) }); - return token; + File.Move(temporary, path, true); } private static void WriteJson(string path, T value) @@ -295,29 +341,6 @@ internal sealed class TrayApplicationContext : ApplicationContext Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, JsonSerializer.Serialize(value, ServiceHost.Json), new UTF8Encoding(false)); } - - internal static void ShowToken(string token) - { - using var dialog = new Form - { - Text = "WxAgent 首次配置", - Width = 640, - Height = 210, - StartPosition = FormStartPosition.CenterScreen, - MinimizeBox = false, - MaximizeBox = false, - FormBorderStyle = FormBorderStyle.FixedDialog - }; - var message = new Label { Left = 18, Top = 18, Width = 585, Height = 48, Text = "首次启动已生成本地管理 Token。请复制并妥善保存;Token 不会写入明文文件。" }; - var box = new TextBox { Left = 18, Top = 76, Width = 585, ReadOnly = true, Text = token }; - var copy = new Button { Left = 418, Top = 120, Width = 88, Text = "复制" }; - var close = new Button { Left = 515, Top = 120, Width = 88, Text = "继续" }; - copy.Click += (_, _) => { Clipboard.SetText(token); copy.Text = "已复制"; }; - close.Click += (_, _) => dialog.Close(); - dialog.Controls.AddRange([message, box, copy, close]); - dialog.AcceptButton = close; - dialog.ShowDialog(); - } } internal static class ServiceSettingsEditor @@ -327,12 +350,13 @@ internal static class ServiceSettingsEditor if (!Uri.TryCreate(current.ListenUrl, UriKind.Absolute, out var uri)) throw new InvalidDataException("ListenUrl is invalid."); var host = uri.Host.Trim('[', ']'); + var token = current.AccessToken ?? ServiceOptions.GenerateToken(); ServiceOptions? result = null; using var form = new Form { Text = "WxAgent 服务设置", Width = 620, - Height = 430, + Height = 355, StartPosition = FormStartPosition.CenterScreen, MinimizeBox = false, MaximizeBox = false, @@ -342,23 +366,28 @@ internal static class ServiceSettingsEditor var hostBox = new TextBox { Left = 145, Top = 14, Width = 245, Text = host }; var portBox = new NumericUpDown { Left = 400, Top = 14, Width = 90, Minimum = 1, Maximum = 65535, Value = uri.Port }; var external = new CheckBox { Left = 145, Top = 52, Width = 430, Text = "允许远程机器连接(仅在可信内网启用)", Checked = current.AllowExternal }; - var sessionLabel = new Label { Left = 18, Top = 91, Width = 120, Text = "监听会话" }; - var sessionBox = new TextBox { Left = 145, Top = 87, Width = 345, Text = current.ListenerSession, MaxLength = 200 }; - var queueLabel = new Label { Left = 18, Top = 129, Width = 120, Text = "队列容量" }; - var queueBox = new NumericUpDown { Left = 145, Top = 125, Width = 90, Minimum = 1, Maximum = 100, Value = current.QueueCapacity }; - var events = new CheckBox { Left = 145, Top = 163, Width = 430, Text = "启用监听事件流(仅受控测试使用)", Checked = current.EnableListenerEvents }; - var files = new Label - { - Left = 18, Top = 205, Width = 560, Height = 58, - Text = $"凭据文件:{current.CredentialFile}{Environment.NewLine}数据目录:{current.DataDirectory}", AutoEllipsis = true - }; + var tokenLabel = new Label { Left = 18, Top = 91, Width = 120, Text = "访问凭据" }; + var tokenBox = new TextBox { Left = 145, Top = 87, Width = 345, Text = token, MaxLength = 0 }; + var copy = new Button { Left = 500, Top = 85, Width = 88, Text = "复制" }; + var regenerate = new Button { Left = 145, Top = 123, Width = 105, Text = "重新生成" }; var note = new Label { - Left = 18, Top = 274, Width = 560, Height = 42, - Text = "本机回环访问不需要 Token。远程访问仍需要 Token。Host/CORS 限制已移除,请用防火墙限制端口。" + Left = 18, Top = 170, Width = 570, Height = 72, + Text = "仅保留一个访问凭据,可直接输入自定义内容(不能为空或包含空白字符,不限制长度)。\n保存后新凭据立即生效,重新生成会使旧凭据失效。本机回环访问不需要凭据。" + }; + var data = new Label + { + Left = 18, Top = 246, Width = 570, Height = 24, + Text = $"数据目录:{current.DataDirectory}", AutoEllipsis = true + }; + var save = new Button { Left = 370, Top = 285, Width = 105, Text = "保存" }; + var cancel = new Button { Left = 485, Top = 285, Width = 105, Text = "取消" }; + copy.Click += (_, _) => { Clipboard.SetText(tokenBox.Text); copy.Text = "已复制"; }; + regenerate.Click += (_, _) => + { + if (MessageBox.Show("重新生成并保存后当前访问凭据会立即失效,继续吗?", "WxAgent", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) == DialogResult.OK) + tokenBox.Text = ServiceOptions.GenerateToken(); }; - var save = new Button { Left = 370, Top = 335, Width = 105, Text = "保存" }; - var cancel = new Button { Left = 485, Top = 335, Width = 105, Text = "取消" }; save.Click += (_, _) => { if (!IPAddress.TryParse(hostBox.Text.Trim().Trim('[', ']'), out var address)) @@ -374,11 +403,9 @@ internal static class ServiceSettingsEditor AllowExternal = external.Checked, AllowedHosts = current.AllowedHosts, AllowedOrigins = current.AllowedOrigins, + AccessToken = tokenBox.Text, CredentialFile = current.CredentialFile, - DataDirectory = current.DataDirectory, - QueueCapacity = (int)queueBox.Value, - ListenerSession = sessionBox.Text.Trim(), - EnableListenerEvents = events.Checked + DataDirectory = current.DataDirectory }; try { edited.Validate(); } catch (Exception exception) @@ -390,73 +417,10 @@ internal static class ServiceSettingsEditor form.Close(); }; cancel.Click += (_, _) => form.Close(); - form.Controls.AddRange([listenLabel, hostBox, portBox, external, sessionLabel, sessionBox, queueLabel, queueBox, events, files, note, save, cancel]); + form.Controls.AddRange([listenLabel, hostBox, portBox, external, tokenLabel, tokenBox, copy, regenerate, note, data, save, cancel]); form.AcceptButton = save; form.CancelButton = cancel; form.ShowDialog(); return result; } } - -internal static class CredentialEditor -{ - public static void Show(string path) - { - var credentials = Read(path).ToList(); - using var form = new Form - { - Text = "WxAgent 访问凭据", - Width = 620, - Height = 390, - StartPosition = FormStartPosition.CenterScreen, - MinimizeBox = false, - MaximizeBox = false, - FormBorderStyle = FormBorderStyle.FixedDialog - }; - var label = new Label { Left = 18, Top = 14, Width = 570, Height = 38, Text = "Token 只显示一次。撤销后立即失效;凭据文件只保存摘要,不保存明文 Token。" }; - var list = new ListBox { Left = 18, Top = 58, Width = 570, Height = 210 }; - var add = new Button { Left = 18, Top = 286, Width = 120, Text = "新建 Token" }; - var revoke = new Button { Left = 148, Top = 286, Width = 120, Text = "撤销选中" }; - var close = new Button { Left = 470, Top = 286, Width = 118, Text = "关闭" }; - void RefreshList() - { - list.Items.Clear(); - foreach (var credential in credentials) - list.Items.Add($"{credential.PrincipalId} · {string.Join(", ", credential.Permissions)}"); - revoke.Enabled = list.SelectedIndex >= 0 && credentials.Count > 1; - } - list.SelectedIndexChanged += (_, _) => revoke.Enabled = list.SelectedIndex >= 0 && credentials.Count > 1; - add.Click += (_, _) => - { - var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); - credentials.Add(new ServiceCredential($"local-{Guid.NewGuid():N}"[..14], ServiceOptions.HashToken(token), ["read", "content", "manage"], [])); - Write(path, credentials); - RefreshList(); - TrayApplicationContext.ShowToken(token); - }; - revoke.Click += (_, _) => - { - if (list.SelectedIndex < 0 || credentials.Count <= 1) return; - if (MessageBox.Show("撤销选中的 Token?现有连接会立即失效。", "WxAgent", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) != DialogResult.OK) return; - credentials.RemoveAt(list.SelectedIndex); - Write(path, credentials); - RefreshList(); - }; - close.Click += (_, _) => form.Close(); - form.Controls.AddRange([label, list, add, revoke, close]); - form.AcceptButton = close; - RefreshList(); - form.ShowDialog(); - } - - private static IReadOnlyList Read(string path) => - JsonSerializer.Deserialize(File.ReadAllText(path), ServiceHost.Json) - ?? throw new InvalidDataException("credentials.json is empty."); - - private static void Write(string path, IReadOnlyList credentials) - { - var temporary = path + ".tmp"; - File.WriteAllText(temporary, JsonSerializer.Serialize(credentials, ServiceHost.Json), new UTF8Encoding(false)); - File.Move(temporary, path, true); - } -} diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs index db5cc20..ec8ff61 100644 --- a/src/WxAgent.Windows/WechatChatClient.Management.cs +++ b/src/WxAgent.Windows/WechatChatClient.Management.cs @@ -100,43 +100,29 @@ public static partial class WechatChatClient public static Task GetMyInfoAsync(CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, automation) => { - var more = FindByAutomationId(main, "MainView.main_tabbar.tabbar_setting") - ?? main.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)) - .FirstOrDefault(button => IsActionable(button) && string.Equals(SafeName(button), "更多", StringComparison.Ordinal)); - more?.Click(); - await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickProfileAvatar(main); var desktop = automation.GetDesktop(); - var menu = desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window)) - .FirstOrDefault(window => string.Equals(SafeName(window), "Weixin", StringComparison.Ordinal)); - var settingsButton = (menu ?? desktop).FindAllDescendants(cf => cf.ByControlType(ControlType.Button)) - .FirstOrDefault(button => IsActionable(button) && string.Equals(SafeName(button), "设置", StringComparison.Ordinal)); - settingsButton?.Click(); - - AutomationElement? settings = null; - for (var attempt = 0; attempt < 30 && settings is null; attempt++) + AutomationElement? profile = null; + for (var attempt = 0; attempt < 30 && profile is null; attempt++) { - settings = desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window)) - .FirstOrDefault(window => string.Equals(SafeAutomationId(window), "PreferenceWindow", StringComparison.Ordinal) - || string.Equals(SafeName(window), "设置", StringComparison.Ordinal)); - if (settings is null) await Task.Delay(100, cancellationToken).ConfigureAwait(false); + profile = desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .FirstOrDefault(window => IsActionable(window) + && string.Equals(SafeName(window), WechatLocators.ProfileWindowTitle, StringComparison.Ordinal) + && FindByAutomationId(window, WechatLocators.ProfileDisplayName) is not null); + if (profile is null) await Task.Delay(100, cancellationToken).ConfigureAwait(false); } - if (settings is null) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account settings window was not found."); + if (profile is null) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account profile window was not found."); try { - var values = settings.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) - .Select(SafeName).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray(); - var accountIndex = Array.FindIndex(values, value => string.Equals(value, "账号", StringComparison.Ordinal)); - var accountValues = accountIndex >= 0 - ? values.Skip(accountIndex + 1).TakeWhile(value => value is not ("登录方式" or "存储" or "退出登录")).ToArray() - : values; - var wechatId = ValueAfter(values, "微信号") ?? accountValues.FirstOrDefault(LooksLikeWechatId); - var displayName = ValueAfter(values, "昵称") ?? accountValues.FirstOrDefault(value => !string.Equals(value, wechatId, StringComparison.Ordinal) - && value is not ("退出登录" or "自动")); - if (string.IsNullOrWhiteSpace(wechatId) && string.IsNullOrWhiteSpace(displayName)) - throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account identity was not found in settings."); - return new WechatAccountSnapshot(displayName ?? wechatId ?? string.Empty, wechatId, ValueAfter(values, "地区")); + var displayName = SafeName(FindByAutomationId(profile, WechatLocators.ProfileDisplayName)); + var wechatId = SafeName(FindByAutomationId(profile, WechatLocators.ProfileWechatId)); + if (string.IsNullOrWhiteSpace(displayName) && string.IsNullOrWhiteSpace(wechatId)) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account identity was not found in the profile window."); + return new WechatAccountSnapshot( + string.IsNullOrWhiteSpace(displayName) ? wechatId : displayName, + string.IsNullOrWhiteSpace(wechatId) ? null : wechatId); } - finally { settings.AsWindow().Close(); } + finally { profile.AsWindow().Close(); } }, cancellationToken); /// Reads non-group contact rows, up to maxCount. Use the paged API for completeness and account selection. @@ -595,6 +581,18 @@ public static partial class WechatChatClient throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Person checkbox '{name}' was not exposed."); } + // ponytail: WeChat exposes the avatar as a nameless custom widget; use the first rail slot until it exposes a stable UIA locator. + private static void ClickProfileAvatar(AutomationElement main) + { + var tabBar = FindByAutomationId(main, WechatLocators.MainTabBar) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat navigation bar was not found."); + var bounds = tabBar.BoundingRectangle; + if (bounds.Width <= 0 || bounds.Height <= bounds.Width) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat navigation bar has no usable bounds."); + ExecuteInputStep("open-profile", () => Mouse.Click( + new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Width), MouseButton.Left)); + } + private static bool IsActionable(AutomationElement element) => !element.Properties.IsOffscreen.ValueOrDefault && element.Properties.IsEnabled.ValueOrDefault && !element.BoundingRectangle.IsEmpty; @@ -658,9 +656,6 @@ public static partial class WechatChatClient return values.Length == 0 ? null : new WechatNewFriendSnapshot(values[0], values.Length > 1 ? values[^1] : "unknown"); } - private static bool LooksLikeWechatId(string value) => - value.Length is >= 6 and <= 20 && char.IsLetter(value[0]) && value.All(character => char.IsLetterOrDigit(character) || character is '_' or '-'); - private static string? ValueAfter(IReadOnlyList values, string label) { for (var index = 0; index < values.Count - 1; index++) diff --git a/tests/WxAgent.Service.Tests/EventPumpTests.cs b/tests/WxAgent.Service.Tests/EventPumpTests.cs index 3c06b3d..fd6eb46 100644 --- a/tests/WxAgent.Service.Tests/EventPumpTests.cs +++ b/tests/WxAgent.Service.Tests/EventPumpTests.cs @@ -16,24 +16,22 @@ public sealed class EventPumpTests } [Fact] - public async Task PumpForwardsOnlyToAuthorizedPrincipals() + public async Task PumpForwardsToTheConfiguredPrincipal() { var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var options = new ServiceOptions { DataDirectory = dir, CredentialFile = Path.Combine(dir, "credentials.json") }; File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { - new ServiceCredential("alice", new string('A', 64), ["read"], []), - new ServiceCredential("scoped", new string('B', 64), ["read"], ["other-account"]) + new ServiceCredential("alice", new string('A', 64), ["read"], []) })); try { - var hub = new EventHub(); var all = hub.Subscribe("alice", null); var scoped = hub.Subscribe("scoped", null); + var hub = new EventHub(); var subscription = hub.Subscribe("alice", null); using var pump = new EventPump(new SourceBackend(), hub, options); await pump.StartAsync(default); var ct = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - while (!hub.TryGet(all.SubscriptionId, "alice", out var alice) || !alice.Channel.Reader.TryRead(out _)) + while (!hub.TryGet(subscription.SubscriptionId, "alice", out var current) || !current.Channel.Reader.TryRead(out _)) await Task.Delay(10, ct.Token); - Assert.False(hub.TryGet(scoped.SubscriptionId, "scoped", out var no) && no.Channel.Reader.TryRead(out _)); await pump.StopAsync(default); } finally { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } diff --git a/tests/WxAgent.Service.Tests/OperationQueueTests.cs b/tests/WxAgent.Service.Tests/OperationQueueTests.cs index 9b5687b..7b0a4fb 100644 --- a/tests/WxAgent.Service.Tests/OperationQueueTests.cs +++ b/tests/WxAgent.Service.Tests/OperationQueueTests.cs @@ -23,7 +23,7 @@ public sealed class OperationQueueTests try { using var store = new OperationStore(options); - using var queue = new OperationQueue(store, options, new ServiceSecurity(options)); + using var queue = new OperationQueue(store, new ServiceSecurity(options)); await queue.StartAsync(default); var first = queue.Submit(identity, "account", capability, "first", "one", async _ => { entered.SetResult(); await release.Task; }); await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); diff --git a/tests/WxAgent.Service.Tests/ServiceBoundaryTests.cs b/tests/WxAgent.Service.Tests/ServiceBoundaryTests.cs index a1bbfc0..83c7154 100644 --- a/tests/WxAgent.Service.Tests/ServiceBoundaryTests.cs +++ b/tests/WxAgent.Service.Tests/ServiceBoundaryTests.cs @@ -4,6 +4,7 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; using WxAgent.Service; using Xunit; @@ -19,7 +20,8 @@ public sealed class ServiceBoundaryTests new("accounts-list", true, true, true, false, false, "read", false, 30, null, []), new("sessions-list", true, true, true, true, false, "read", false, 30, null, []), new("messages-read", true, true, true, true, false, "read", false, 30, null, []), - new("contacts-list", true, true, true, false, false, "read", false, 30, null, []) + new("contacts-list", true, true, true, false, false, "read", false, 30, null, []), + new("send-text", true, false, false, true, true, "write", false, 30, "Write validation is pending.", []) ]; public Task StatusAsync(CancellationToken ct) => Task.FromResult(new { ok = true }); public Task> AccountsAsync(CancellationToken ct) => Task.FromResult>([new("account-1", null, null, null, "fingerprint", false)]); @@ -48,12 +50,125 @@ public sealed class ServiceBoundaryTests finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } } + [Fact] + public void CredentialFilesAcceptTrayStyleCamelCaseJson() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + try + { + var token = new string('C', 43); + var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] + { + new { principalId = "tray", tokenSha256 = ServiceOptions.HashToken(token), permissions = new[] { "read" }, accountIds = Array.Empty() } + })); + var credentials = options.ReadCredentials(); + Assert.Single(credentials); + Assert.Equal("tray", credentials[0].PrincipalId); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void CustomAccessCredentialsHaveNoLengthLimit() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + try + { + var token = "x"; + var options = new ServiceOptions + { + AccessToken = token, + CredentialFile = Path.Combine(dir, "credentials.json"), + DataDirectory = dir + }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] + { + new ServiceCredential("custom", ServiceOptions.HashToken(token), ["read"], []) + })); + options.Validate(); + Assert.Equal("custom", new ServiceSecurity(options).AuthenticateToken(token)?.PrincipalId); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void RuntimeLogWritesFullOperationalMessagesToTheRequestedPath() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + try + { + var path = Path.Combine(dir, "wxagent.log"); + RuntimeLog.Append(path, Microsoft.Extensions.Logging.LogLevel.Information, "test", "full diagnostic message"); + var log = File.ReadAllText(path); + Assert.Contains("full diagnostic message", log); + Assert.Contains("[Information] test", log); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public async Task OperationListIsPrincipalScopedAndSupportsCallerAccountFiltering() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var token = new string('A', 43); + var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read"], []) })); + await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer()); + try + { + await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088"); + var store = app.Services.GetRequiredService(); + var first = store.Enqueue("p", "account-1", "read-demo", "key-1", "digest-1", false, TimeSpan.FromMinutes(1), 100).Record; + store.Transition(first.Id, "Succeeded", "complete"); + var second = store.Enqueue("p", "account-2", "read-demo", "key-2", "digest-2", false, TimeSpan.FromMinutes(1), 100).Record; + store.Transition(second.Id, "Succeeded", "complete"); + var other = store.Enqueue("other", "account-1", "read-demo", "key-3", "digest-3", false, TimeSpan.FromMinutes(1), 100).Record; + store.Transition(other.Id, "Succeeded", "complete"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var all = await client.GetFromJsonAsync("/api/v1/operations?limit=1"); + Assert.Single(all.GetProperty("items").EnumerateArray()); + Assert.True(all.GetProperty("hasMore").GetBoolean()); + Assert.DoesNotContain("principalId", all.GetProperty("items")[0].EnumerateObject().Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + + var filtered = await client.GetFromJsonAsync("/api/v1/operations?accountId=account-2&limit=50"); + Assert.Single(filtered.GetProperty("items").EnumerateArray()); + Assert.Equal("account-2", filtered.GetProperty("items")[0].GetProperty("accountId").GetString()); + } + finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } + } + + [Fact] + public async Task DisabledTextSendDoesNotCreateAnOperation() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var token = new string('B', 43); + var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] { new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], []) })); + await using var app = ServiceHost.Build(options, new Backend(), b => b.WebHost.UseTestServer()); + try + { + await app.StartAsync(); using var client = app.GetTestClient(); client.BaseAddress = new Uri("http://localhost:5088"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await client.PostAsJsonAsync("/api/v1/operations", new + { + kind = "send-text", accountId = "account-1", targetId = "session-1", text = "test", + idempotencyKey = "send-1", confirmed = true + }); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal("CapabilityDisabled", (await response.Content.ReadFromJsonAsync()).GetProperty("error").GetProperty("code").GetString()); + } + finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); } + } + [Fact] public void LoopbackRequestsCanUseLocalIdentityWithoutToken() { var options = new ServiceOptions { CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DataDirectory = Path.GetTempPath() }; var security = new ServiceSecurity(options); var local = new DefaultHttpContext(); + local.Request.Method = HttpMethods.Get; local.Connection.RemoteIpAddress = IPAddress.Loopback; var identity = security.AuthenticateRequest(local); Assert.True(identity.LocalOnly); @@ -72,4 +187,18 @@ public sealed class ServiceBoundaryTests try { Assert.Throws(() => options.Validate()); } finally { Directory.Delete(dir, true); } } + + [Fact] + public void MultipleTokensAreRejected() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] + { + new ServiceCredential("one", new string('A', 64), ["read"], []), + new ServiceCredential("two", new string('B', 64), ["read"], []) + })); + try { Assert.Throws(() => options.ReadCredentials()); } + finally { Directory.Delete(dir, true); } + } } diff --git a/tests/WxAgent.Service.Tests/ServiceTests.cs b/tests/WxAgent.Service.Tests/ServiceTests.cs index bfb5c05..f38532b 100644 --- a/tests/WxAgent.Service.Tests/ServiceTests.cs +++ b/tests/WxAgent.Service.Tests/ServiceTests.cs @@ -45,10 +45,10 @@ public sealed class ServiceTests client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode); client.DefaultRequestHeaders.Add("Origin", "http://evil.invalid"); - Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode); client.DefaultRequestHeaders.Remove("Origin"); client.DefaultRequestHeaders.Host = "evil.invalid"; - Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode); client.DefaultRequestHeaders.Host = null; client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream");