mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
feat: 新增 API(capabilities / 退群监控 / 自动化 / 群统计)
This commit is contained in:
+124
-2
@@ -8,7 +8,9 @@
|
||||
- API 前缀:`/api/v1`
|
||||
- 默认只监听 loopback;不要把它当作公网服务。
|
||||
- `/api/v1/health` 无需 Token;其他端点需要 `Authorization: Bearer <TOKEN>`。
|
||||
- 请求体使用 JSON;响应为 JSON。
|
||||
- 请求体使用 JSON,单个请求体最大 `1 MiB`;超限返回 `413`。
|
||||
- 错误响应包含 `requestId`,响应头包含 `X-Request-Id`。客户端可传入 1-128 位的 `[A-Za-z0-9._:-]` 标识,否则服务端会生成 UUID。
|
||||
- 不支持的 HTTP method 返回 `405` 和 `Allow` 响应头。
|
||||
|
||||
## 最小请求
|
||||
|
||||
@@ -55,10 +57,130 @@ Token 由应用生成并保存在本机,**不接受用环境变量覆盖**:A
|
||||
| POST | `/api/v1/scheduled-reports/{id}/disable` | 暂停定时日报任务 | 无 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/run` | 立即执行一次并返回 execution | 无 |
|
||||
| GET | `/api/v1/scheduled-reports/{id}/executions` | 查询某个任务的执行记录 | 无 |
|
||||
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 复用已有 PNG 重试发送 | 无 |
|
||||
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 兼容占位路由;当前返回 `501 not_supported` | 无 |
|
||||
| GET | `/api/v1/capabilities` | TraceMemo 应用能力和可用状态 | 无 |
|
||||
| GET | `/api/v1/automations` | 自动化规则列表 | 可选 `type`、`enabled` |
|
||||
| POST | `/api/v1/automations` | 创建默认停用的自动化规则 | Automation draft JSON |
|
||||
| POST | `/api/v1/automations/validate` | 校验规则,不保存、不执行 | Automation draft JSON |
|
||||
| GET | `/api/v1/automations/{id}` | 查询单条自动化规则 | 无 |
|
||||
| PATCH | `/api/v1/automations/{id}` | 更新规则配置 | 可变配置字段 JSON |
|
||||
| DELETE | `/api/v1/automations/{id}` | 删除自定义规则 | 系统内置规则受保护 |
|
||||
| POST | `/api/v1/automations/{id}/enable` | 校验并启用规则 | 无 |
|
||||
| POST | `/api/v1/automations/{id}/disable` | 停用规则 | 无 |
|
||||
| GET | `/api/v1/automations/executions` | 查询自动化执行记录 | `ruleId`、`status`、`since`、`until`、`limit` |
|
||||
| GET | `/api/v1/monitors/group-exits` | 查看退群监控状态 | 无 |
|
||||
| PATCH | `/api/v1/monitors/group-exits` | 配置监控群范围或启停 | `enabled`、`monitoredConversationIds` |
|
||||
| GET | `/api/v1/monitors/group-exits/events` | 查询退群事件历史 | `conversationId`、`since`、`until`、`limit` |
|
||||
| GET | `/api/v1/groups/{conversationId}/member-stats` | 查询群成员活跃统计 | 必填 `conversationId`、`start`、`end` |
|
||||
|
||||
`/api/v1/query/*` 是一组结构化的 Query 端点,见下方[LLM-friendly Query Tool API](#llm-friendly-query-tool-api)。
|
||||
|
||||
## Application Capabilities
|
||||
|
||||
`GET /api/v1/capabilities` 描述 TraceMemo 应用级能力和当前运行环境;`GET /api/v1/query/capabilities` 只描述结构化 Query primitive,两者不是同一份目录。应用能力使用 `supported` 和 `available` 分开表示“代码支持”与“当前可用”;运行时原因使用稳定的简短 code,不返回 Token、数据库路径、微信密钥或 sender 诊断路径。
|
||||
|
||||
响应包含应用版本、数据库 readiness、Query、Automation、退群监控、群统计,以及个人微信/iLink 的能力状态。`groupExitMonitor.operations` 当前声明 `read_state`、`configure_scope`、`enable`、`disable`、`list_events`;`groupStats.operations` 当前声明 `member_stats`。能力声明不会触发监控扫描或群统计查询。
|
||||
|
||||
```bash
|
||||
: "${TRACEMEMO_API_TOKEN:?Set TRACEMEMO_API_TOKEN from API Center}"
|
||||
BASE="http://127.0.0.1:6131/api/v1"
|
||||
AUTH="Authorization: Bearer $TRACEMEMO_API_TOKEN"
|
||||
curl -H "$AUTH" "$BASE/capabilities"
|
||||
```
|
||||
|
||||
## Group Exit Monitor API
|
||||
|
||||
退群监控只负责“监测哪些群、发现了哪些退群事实”。退群后是否通知、通知到哪里以及通知模板,仍由 `leave_notification` Automation singleton 负责;修改监控范围不会隐式修改该 Automation。
|
||||
|
||||
### 查看和配置监控
|
||||
|
||||
```bash
|
||||
curl -H "$AUTH" "$BASE/monitors/group-exits"
|
||||
|
||||
curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
"$BASE/monitors/group-exits" \
|
||||
-d '{"enabled":true,"monitoredConversationIds":["123@chatroom"]}'
|
||||
```
|
||||
|
||||
`monitoredConversationIds` 只接受当前联系人列表中精确存在的群 `roomId`(例如 `xxx@chatroom`),不接受群名、md5、个人联系人、重复或空 ID。请求至少提供 `enabled` 或 `monitoredConversationIds` 其中一个;传空数组表示清空监控范围。服务会先校验全部群,再执行一次原子配置。PATCH 返回最终完整状态。
|
||||
|
||||
状态中的 `eventCount` 是持久化退群事件总数,`lastCheckedAt`/`lastReadAt` 为空时返回 `null`。GET 不会调用 `checkNow()`,也不会触发通知发送。
|
||||
|
||||
### 查询退群事件
|
||||
|
||||
```bash
|
||||
curl -G -H "$AUTH" "$BASE/monitors/group-exits/events" \
|
||||
--data-urlencode 'conversationId=123@chatroom' \
|
||||
--data-urlencode 'since=2026-10-01T00:00:00+07:00' \
|
||||
--data-urlencode 'until=2026-10-02T23:59:59+07:00' \
|
||||
--data-urlencode 'limit=50'
|
||||
```
|
||||
|
||||
时间参数必须是带 offset 的 ISO-8601;默认 `limit=50`,最大 200。事件按 `detectedAt` 升序返回。事件 DTO 使用 `eventId`、稳定的 `conversationId` 和 `memberId`,并把时间输出为 ISO-8601;当前整体已读状态不会伪造成 event-level `read` 字段。当前未开放 clear events、markRead 或 checkNow HTTP 路由。
|
||||
|
||||
一个典型 Agent 工作流是:先通过 `/resolve` 或 `/contact` 找到稳定群 ID,再 PATCH monitor scope;如需通知,再单独 PATCH `leave_notification` Automation,调用 `/automations/validate`,最后启用规则。
|
||||
|
||||
## Group Member Stats API
|
||||
|
||||
```bash
|
||||
curl -G -H "$AUTH" "$BASE/groups/123%40chatroom/member-stats" \
|
||||
--data-urlencode 'start=2026-09-01T00:00:00+07:00' \
|
||||
--data-urlencode 'end=2026-10-01T00:00:00+07:00'
|
||||
```
|
||||
|
||||
`conversationId` 必须是当前联系人列表中精确存在的群 `roomId`;不存在返回 `NOT_FOUND`,个人联系人返回 `NOT_GROUP_CONVERSATION`。`start` 和 `end` 必须同时提供,且使用带 offset 的 ISO-8601,`start` 不能晚于 `end`。HTTP adapter 只负责把稳定群 ID 解析为内部 md5 并调用现有 `GroupStatsService`,不会在 HTTP 层重新统计消息。
|
||||
|
||||
响应中的 `activeMembers` 和 `silentMembers` 都只描述当前成员名单;成员使用 `memberId`,活跃成员的 `lastMessageAt` 和 `range` 时间均为 ISO-8601。`freshness`、`complete`、`limitations` 必须原样保留,`unattributedMessages` 与 `excludedSystemMessages` 用于诊断,`firstMessageAt` 没有消息时为 `null`。当前成员统计不等于完整历史成员统计,`limitations` 表达的退群成员或未归档时段不能从文本中推导成额外的 `formerMembers`,也不会伪造 `totalMessageCount`。
|
||||
|
||||
## Automation API
|
||||
|
||||
`/api/v1/automations*` 是 Automation 的 canonical HTTP API,读写唯一的 `AutomationRuleStore`。它支持当前真实规则类型:`daily_report`、`scheduled_report`、`leave_notification`。本 API 不提供立即执行、重试或清理执行记录。
|
||||
|
||||
旧 `/api/v1/scheduled-reports*` 保持兼容,不设移除日期;它是面向旧 DTO 的受限 compatibility API,不是第二份存储,也不能表示所有新的定时日报目标和配置。新的 Agent 集成应使用 `/automations`。
|
||||
|
||||
创建和校验规则时 `enabled` 只能缺省或为 `false`。创建成功后必须调用 `/automations/{id}/enable` 才会启用。启用会重新校验当前规则;数据库未就绪、目标无法解析或配置无效时不会启用。`PATCH` 只接受规则配置字段,不可改 `ruleType`、`id`、创建/更新时间或 `enabled`;启停必须使用独立 endpoint。未知字段和未知枚举会被拒绝。
|
||||
|
||||
会话范围优先传 `wxid`、`roomId`(如 `xxx@chatroom`)或 canonical conversation ID。唯一匹配的联系人名可被解析为稳定 ID;重名会返回 `ambiguous_contact`,不会猜测。`daily_report.conditions.conversationIds` 在对外 API 中使用稳定 ID,Store 内部仍沿用既有 md5 口径。
|
||||
|
||||
校验请求不落盘、不发消息,也不执行规则。`valid: false` 时查看 `issues`;有效时 `normalized` 是经 ID 解析后的草稿,`effects` 描述启用后的动作,定时日报另外返回按本机时区计算的 `nextRunAt`。
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "产品群每日日报",
|
||||
"ruleType": "scheduled_report",
|
||||
"scheduledReport": {
|
||||
"schedule": { "time": "20:00" },
|
||||
"report": {
|
||||
"sourceConversationId": "wxid_product@chatroom",
|
||||
"range": "today",
|
||||
"messageTypes": ["text", "image"],
|
||||
"templateId": "v1",
|
||||
"memberNameMode": "groupNickname",
|
||||
"timeoutSeconds": 300
|
||||
},
|
||||
"target": { "type": "file_transfer" },
|
||||
"postfixText": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
执行历史只读,默认最多返回 50 条,`limit` 范围是 1-200。`since` 和 `until` 接受带时区的 ISO-8601 时间;execution 本身最多留存 200 条。`running` 记录的 `finishedAt` 为 `null`。
|
||||
|
||||
新 Agent API 的错误格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_FAILED",
|
||||
"message": "自动化规则校验失败",
|
||||
"details": []
|
||||
},
|
||||
"requestId": "..."
|
||||
}
|
||||
```
|
||||
|
||||
常见错误码包括 `UNAUTHORIZED`、`METHOD_NOT_ALLOWED`、`PAYLOAD_TOO_LARGE`、`INVALID_ARGUMENT`、`NOT_FOUND`、`NOT_GROUP_CONVERSATION`、`DATABASE_NOT_READY`、`VALIDATION_FAILED`、`SINGLETON_RULE`、`PROTECTED_RULE` 和 `PERSISTENCE_FAILED`。`leave_notification` 是固定单例:可读取、修改和启停,但不能创建第二条或删除。内置 `@我生成日报` 同样不能通过 HTTP 删除。
|
||||
|
||||
### 这些端点与实时机器人有什么关系
|
||||
|
||||
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# TraceMemo Local HTTP API / Agent API 能力审计
|
||||
|
||||
本报告基于当前代码、文档、IPC、Renderer 调用和相关测试做静态审计,对应当前发布应用版本 `2.5.0`;不连接真实微信数据库,也不修改生产代码、API Center 或测试。
|
||||
|
||||
主要代码入口:[http-server.ts](../../src/main/http-server.ts)、[automation-rule-store.ts](../../src/main/services/automation-rule-store.ts)、[group-exit-monitor-service.ts](../../src/main/services/group-exit-monitor-service.ts)、[group-stats-service.ts](../../src/main/services/group-stats-service.ts)、[api.md](../agent/api.md)。
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
当前 HTTP 层声明了 **30 个 Method + Path 模板**:15 个 GET、12 个 POST、1 个 PATCH、1 个 DELETE、1 个 HEAD。共享 `LOCAL_API_ENDPOINTS` 定义 **14 个测试项**,但 Renderer 的 `API_ENDPOINTS` 实际只展示 **12 项**;个人微信能力和 `GET /scheduled-reports` 虽有共享定义,界面没有展示。界面也没有结构化 Query、媒体读取、定时日报写操作、退群监控、群统计、Automation 通用资源或执行日志查询。
|
||||
|
||||
当前不缺成熟的 Query primitive:结构化读取消息、Knowledge 搜索、消息前后文、会话概览和图片 OCR 搜索已经有 HTTP 契约。真正的能力缺口集中在 **监控配置、自动化规则通用管理、群员统计、执行历史、全局能力发现、发送状态和报告历史**。
|
||||
|
||||
几个影响后续设计的代码事实:
|
||||
|
||||
1. 定时日报已迁入 `AutomationRuleStore`。当前 `/scheduled-reports` 是旧 HTTP 契约的兼容投影,不是第二份活动规则存储;但它只表达“生成后发回来源群”,其他当前合法目标不会出现在该兼容 API 列表中。
|
||||
2. 退群监控只负责监测范围、快照和事件历史;退群后如何通知由唯一的 `leave_notification` Automation 规则负责。二者应分别建资源。
|
||||
3. 群统计 Service 足以提供活跃成员、当前沉默成员、时间范围、新鲜度和限制说明;它没有结构化的 former member 列表,也没有覆盖所有发言者的群总消息数。
|
||||
4. 现有微信 Action Gateway 有策略、审计和幂等骨架,但策略目前主要校验收件人,以及 Automation purpose allowlist;手动用户 purpose 默认可放行。它只发个人微信,现有 `/agent/send` 则走 iLink 的 `WechatSendGateway`。两者不能直接视为统一的 Agent 安全边界。
|
||||
5. HTTP 静态 GET handler 大多没有 method guard;对这些路径发 POST、PATCH 或 DELETE 仍会执行读处理。Automation store 的规则落盘失败会记录日志,但仍将内存中的规则返回为成功。
|
||||
|
||||
建议先做应用级能力发现、退群监控资源、严格校验后的 Automation CRUD/执行查询;发送和“立即执行”放到有 dry-run、明确确认、幂等键和 Action 审计的后续阶段。
|
||||
|
||||
## 2. Current API Inventory
|
||||
|
||||
以下按 `http-server.ts` 的路由分派和动态 route factory 盘点。静态读路由的 `Method` 是当前文档和产品语义的预期方法;实际接受方法的差异见本节末尾。
|
||||
|
||||
| Method | Path | 能力 | Read/Write/Execute | Service | API Center | 文档 | Agent 价值 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| GET | `/api/v1/health` | HTTP 与数据库 ready 状态;唯一免 Token 路径 | Read | `isReady()` | 是 | 是 | 高:连通性 |
|
||||
| GET | `/api/v1/current_time` | 本机时间、时区、日期 | Read | JavaScript `Date` | 是 | 是 | 中:相对日期换算 |
|
||||
| GET | `/api/v1/contact` | 联系人和群列表;`filter`、`type`;使用异步 hydration | Read | `chat-service.listContactsAsync` | 是 | 是 | 高:标识发现与 resolve 前置 |
|
||||
| GET | `/api/v1/chatroom` | 群聊列表;`keyword`;使用异步 hydration | Read | `chat-service.listContactsAsync` | 是 | 是 | 高:群标识发现 |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话;`limit` 默认 50 | Read | `chat-service.listRecentChat` | 是 | 是 | 高:导航/摘要 |
|
||||
| GET | `/api/v1/chatlog` | 按 talker 和时间读取原始消息;移除 `contentData.aeskey` | Read | `chat-service.listMessages`、`resolveMd5` | 是 | 是 | 高,但旧式、未做结构化分页 |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照;必填 `md5` | Read | `chat-service.getGroupSnapshot` | 是 | 是 | 高:成员身份解析 |
|
||||
| GET | `/api/v1/resolve` | 昵称、wxid、md5 解析为会话 | Read | `chat-service.resolveMd5` | 是 | 是 | 高;新资源宜返回稳定 ID 和歧义候选 |
|
||||
| POST | `/api/v1/report` | 接收完整结构化日报并导出 HTML/PNG;当前拒绝外部 `templateRef` | Write:本地文件 | `group-report-service.exportGroupReport` | 是 | 是 | 低/中:低层渲染契约,Agent 须先拼完整结构 |
|
||||
| POST | `/api/v1/agent/group-report` | 读群消息、调用 AI 生成群总结、导出 HTML/PNG | Execute:AI/本地文件 | `agent-group-report-service.generateAgentGroupReport` | 是 | 是 | 高但有模型费用/数据出站;结果不等同于报告历史记录 |
|
||||
| GET | `/api/v1/agent/status` | Agent Hub、connector、数据 API、数据库状态 | Read | `agentHubService.getStatus` | 是 | 是 | 中:只覆盖 Agent Hub,不是应用总能力 |
|
||||
| POST | `/api/v1/agent/send` | 通过 Agent Hub 连接器发送文字或媒体 | Execute:微信发送 | `agentHubService.testSend` → `WechatSendGateway`/iLink | 是 | 是 | 高但 R2;是测试入口,不含统一 Action policy/幂等确认 |
|
||||
| GET | `/api/v1/wechat-personal/send-capability` | 个人微信 text/image/voice 能力状态 | Read | `PersonalWechatCapabilityService`,由 `ScheduledReportApiService` 包装 | 否(共享定义有,界面未展示) | 是 | 高但只代表 personal,不代表 iLink |
|
||||
| GET | `/api/v1/scheduled-reports` | 列出旧 DTO 可表达的定时日报规则 | Read | `ScheduledReportApiService.list` → 规则投影 | 否(共享定义有,界面未展示) | 是 | 高但不完整:仅 `source_chat` 目标 |
|
||||
| POST | `/api/v1/scheduled-reports` | 创建旧型定时日报,来源群即发送目标 | Write:规则配置 | `ScheduledReportApiService.create` → `AutomationRuleStore` | 否 | 是 | 高;功能受旧 DTO 限制 |
|
||||
| GET | `/api/v1/scheduled-reports/{id}` | 单条旧型日报规则投影 | Read | `ScheduledReportApiService.get` → `AutomationRuleStore` | 否 | 是 | 中/高:只支持兼容投影规则 |
|
||||
| PATCH | `/api/v1/scheduled-reports/{id}` | 修改旧型日报规则 | Write:规则配置 | `ScheduledReportApiService.update` → `AutomationRuleStore` | 否 | 是 | 高但只能改旧字段/目标 |
|
||||
| DELETE | `/api/v1/scheduled-reports/{id}` | 删除旧型日报规则 | Write:删除配置 | `ScheduledReportApiService.delete` → `AutomationRuleStore` | 否 | 是 | 中;新 API 应标 R3 并防护系统规则 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/enable` | 启用规则 | Write:配置/未来执行 | `ScheduledReportApiService.setEnabled` → `AutomationRuleStore` | 否 | 是 | 高;启用后未来可能发送微信 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/disable` | 暂停规则 | Write:配置 | `ScheduledReportApiService.setEnabled` → `AutomationRuleStore` | 否 | 是 | 高 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/run` | 手动触发完整日报规则 | Execute:可能调用 AI、保存历史、发微信 | `ScheduledReportService.runScheduledReportNow` → `AutomationService` | 否 | 是 | 高但 R2;当前无 HTTP 幂等键/确认 |
|
||||
| GET | `/api/v1/scheduled-reports/{id}/executions` | 旧型 execution 历史和新 Automation execution 投影 | Read | `ScheduledReportService.listExecutions` | 否 | 是 | 高但旧响应模型/有限留存 |
|
||||
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 旧文档称复用 PNG 重发 | Execute 路由存在但当前固定 `501 not_supported` | `ScheduledReportApiService.retrySend` | 否 | **路径有,语义已失效** | 无:不能重发 |
|
||||
| GET | `/api/v1/media/{mediaId}` | 读取消息关联的图片二进制 | Read | `http-media-service.readImageMedia` | 否 | 是 | 高:图像证据 |
|
||||
| HEAD | `/api/v1/media/{mediaId}` | 图片资源存在性/响应头 | Read | `http-media-service.readImageMedia` | 否 | 否 | 低/中 |
|
||||
| GET | `/api/v1/query/capabilities` | Query Tool 支持的结构化操作、范围和上限 | Read | `LocalQueryApiService.capabilities` | 否 | 是(独立章节) | 高,但不是 TraceMemo 应用能力清单 |
|
||||
| POST | `/api/v1/query/messages` | 单会话、范围、时间、方向、类型等确定性消息读取 | Read | `LocalQueryApiService.messages` | 否 | 是 | 高:推荐 Query primitive |
|
||||
| POST | `/api/v1/query/search` | Knowledge 关键词检索,返回覆盖、新鲜度和 OCR 命中 | Read | `LocalQueryApiService.search` + `KnowledgeSearchService` | 否 | 是 | 高:必须读取 coverage/freshness |
|
||||
| POST | `/api/v1/query/message-context` | 通过 opaque `messageRef` 读取前后文 | Read | `LocalQueryApiService.context` | 否 | 是 | 高:稳定消息引用 |
|
||||
| POST | `/api/v1/query/conversation-overview` | 单会话范围的概览证据和 source coverage | Read | `LocalQueryApiService.overview` | 否 | 是 | 高:broad summary |
|
||||
|
||||
路由来源:[http-server.ts](../../src/main/http-server.ts#L212)、scheduled/query/media route factory(同文件 L404-L670)。总数是代码中声明的业务方法模板,不代表静态 handler 都正确拒绝其他动词:`/health`、`/current_time`、`/contact`、`/chatroom`、`/recent_chat`、`/chatlog`、`/group_snapshot`、`/resolve`、`/agent/status` 没有检查 `req.method`。这些路径携带错误动词仍会走同一 handler;特别是 `POST /health` 也绕过 Token 检查,因为鉴权例外按 pathname 判断。新/旧路由都应 fail closed 并对不支持的方法返回 405。
|
||||
|
||||
全局 `OPTIONS` 在路由和鉴权前处理;媒体额外支持 HEAD。非 health 路径要求 `Authorization: Bearer …`,但 `readBody` 没有大小上限。路由外层目前没有统一请求 schema、统一错误 envelope 或 request id。
|
||||
|
||||
## 3. Internal Capability Inventory
|
||||
|
||||
这里按产品能力追 Service → IPC/Renderer → HTTP → 外部 Agent,而不是按当前 API 名字扩展。
|
||||
|
||||
| 业务域 | 已有能力及实现 | IPC / Renderer | HTTP 现状 | Agent 结论 |
|
||||
|---|---|---|---|---|
|
||||
| Chat / Contact | 联系人、群聊、最近会话、解析、历史消息、群快照、消息周边上下文、媒体定位;`contact-resolution-service` 可精确匹配别名并返回歧义候选 | `db:getContacts`、`db:getGroupSnapshot`、消息查询/around 等,Chat/Contact/档案 UI | 旧 Reader routes + `/query/*` + 图片 `/media/{id}` | 已有 Read API 基础完整。新配置应使用 `m_nsUsrName` 对应的 wxid/roomId 等稳定 ID,不应把昵称当长期键 |
|
||||
| Query / Knowledge | `messages`、`search`、`message-context`、`conversation-overview`;Knowledge 索引覆盖/新鲜度;Image OCR 可进入 Knowledge 搜索和证据 | `knowledge:search/getStatus/startIndex/cancelIndex`、AI Search UI、`LocalQueryToolExecutor` | 四个 Query 操作均已 HTTP 化;`query/capabilities` 只描述 Query Tool | 没有需要重做的基础 Query primitive。缺的是统一应用 capability/status、更多外层筛选和 API Center 展示 |
|
||||
| Group Analytics | 活跃/沉默当前成员、每人 messageCount/lastMessageTime、窗口时间、memberCount、unattributed/system 消息、firstMessageTime、freshness/complete/limitations;索引未新鲜时最多等待 2 秒并如实降级 | `group-stats:getMemberStats`;聊天页群统计 UI | 无 | P1 候选。当前 query 要求群 `userMd5` + epoch 毫秒。former sender 只以限制文案给出数量,没有 formerMembers 数组/结构化计数;没有覆盖 former sender 的群总消息数字段,需扩 DTO 后再承诺 |
|
||||
| Group Exit Monitor | enabled/running/nativeMonitor 状态、监控 roomId 集合、lastChecked、unread、事件历史;每次回传最多 500 条,但完整事件历史 append-only 长期保存;可立即 check、改范围、启停、筛事件、清历史、mark read | `group-exit-monitor:*`;`GroupExitMonitorWorkspace` | 无 | P0。拆成 monitor state/config、events、check。`checkNow` 可能发现事件并触发自动通知,不是纯读操作;重启监控会重建快照基线,暂停期成员变化不会补报 |
|
||||
| Automation | 实际规则类型:`daily_report`、`scheduled_report`、`leave_notification`。`daily_report` 是现有消息触发条件/动作链,不是任意流程引擎;退群通知为固定 ID singleton。规则 CRUD、enable、执行记录读写均已有 Service/Store | `automation:*`;AutomationWorkspace 有规则、日志、定时执行、启停、删除和状态 UI | 没有通用 Automation API;仅 scheduled-report 兼容映射 | P0。使用 typed rule union;不要把内部任意 draft 原样开放。规则创建当前缺省 enabled=true,未知值会被归一化成默认;需 HTTP 严格校验,先 disabled + validate,再显式 enable |
|
||||
| WeChat Send / Action | `WechatSendGateway` 有 personal/iLink transport resolution、text/image/voice/file 统一类型及 Send Log;`WechatActionGateway` 做 capability preflight、Automation purpose allowlist、Action audit、幂等和 Automation 3 秒间隔 | `wechat-personal:send`、`sendGeneratedTtsVoice`、`wechat-action-log:list`、Agent Hub connector 相关 IPC/UI | `/agent/send` 只走 Agent Hub/iLink 测试发送;个人 capability 有独立 GET;两类日志没有 HTTP | 分 transport 公布 capability;R2 send 通过经审计的业务门面,不直接暴露底层 gateway。现有 Action Gateway 还不是普适安全策略:`triggerType=user` 不按 purpose 限制;普通 `/agent/send` 没传 idempotency key,也没有 Action audit |
|
||||
| Agent Hub | status、connector login/reconnect/disconnect、notification recipient/send、logs、conversation list/detail/clear、入站 inbox retry | `agent-hub:*`;Agent Hub UI | 仅 `/agent/status` 和 `/agent/send`;无 conversation/log HTTP | status 有只读价值。对话记录包含完整收发正文;inbox 包含 context token/raw items。Connector 生命周期、登录 QR/验证码、收件箱和通知 recipient 应保持 internal |
|
||||
| Reports / Templates | 手动 report render/export;AI group report;本地 Report History list/save/update template/delete;内置/已安装模板和市场 catalog/install/uninstall | `report:*`、`report-template:*`、`report-template-market:*`;Reports 与 Template Market UI | `/report` 低层 export,`/agent/group-report` AI 生成;没有 history/template API | P1:只读 Report History 元数据/资产可分离设计。现有 `listGeneratedReports` 会读取每张 PNG 为 base64,并返回结构快照和本机绝对路径,不可原样直出。模板目录可读列入 P2;安装/卸载涉及网络与本地包写入,不宜第一批开放 |
|
||||
| Recall Archive | 后台监听撤回变化,最多按会话存归档消息/撤回记录;chat-service 将 archive merge 到历史读结果 | 没有独立 CRUD IPC;设置开关和消息渲染 | 没有独立 archive API;旧 `/chatlog` 可能随底层消息返回 `recalled` 标记;Query DTO 未声明 recalled 字段 | 不开放原始 archive 管理。后续 Query 应明确返回 `recalled`/来源,避免把已撤回归档当普通消息证据 |
|
||||
| OCR / Image Insight | System OCR 本地识别;image-text-index status/count/start/pause/resume/cancel/clear/repair;Image Insight 读/解密图片并可调用 AI Provider | `system-ocr:*`、`image-text-index:*`、`image:*` IPC;Search/Report UI | OCR 派生文本可通过 `/query/search` 得到;索引管理、单图 AI 分析无 HTTP | 已有搜索能力可用。状态可纳入 capability/status;索引删除、key/decoder 配置、任意图像 AI 分析涉及成本、私密图片和索引破坏,不列第一批 |
|
||||
| 系统 / 数据 / 其他 | account discovery、DB key 管理、数据库 connect/root 重开、设置写入、cache summary/clear、app update、voice/TTS、export/import、Reader Skill 本地安装信息 | 多组 IPC;Settings、Cache、Export、Update、Voice UI | 无相应 HTTP API | 只读脱敏运行状态可按需求列 P2;DB key/root、通用 settings patch、cache 清理、任意文件路径、更新安装、TTS synthesis 等保持 internal |
|
||||
|
||||
### A. Chat / Contact 与 ID 语义
|
||||
|
||||
- `/contact`、`/chatroom` 改用 `listContactsAsync`,因为 macOS Session 可能只有原始 wxid/chatroom id,需要 hydrate 显示名;`ScheduledReportApiService` 却使用同步 `listContacts()` 解析群名。稳定 `talker` 可直接解析,但名称输入在需要 hydration 的运行时可能失败/退化。这是可复用 adapter 应统一异步解析的理由。
|
||||
- ID 现在不是一个口径:旧 Query 的 `scope.conversationId`/`target` 解析为 `Contact.md5`;`group-stats` 传 `userMd5`;监控用 `roomId`(`xxx@chatroom`);新的 scheduled automation 用 `sourceConversationId`(wxid/roomId);老 HTTP 路由混用昵称、wxid、md5。保持已有 Reader 契约不动,新 API facade 应统一对外 canonical `conversationId`(底层当前联系人的稳定 username/wxid 或 roomId),并在 main adapter 转为服务所需 md5。名称只做 resolve,不持久化到规则。
|
||||
- `chatlog` 时间边界是 Unix 秒,Query `absolute` 内部也是秒,而 group stats IPC 是 epoch 毫秒;新 Agent 资源建议用带时区 ISO-8601 输入/输出,并在 facade 单点转换。
|
||||
- `/query/messages` 有 200 上限,`messageRef` 是 opaque 稳定引用;图片 OCR 文字和 Coverage 分开呈现。`/chatlog` 则支持旧 talker/time 风格但读取结果没有同等结构边界;作为兼容 Reader 保留,不作为新 Agent 配置/分析的默认接口。
|
||||
|
||||
### B. Group Exit Monitor 与 Leave Notification
|
||||
|
||||
`GroupExitMonitorService` 的真实 IPC 有 `getState`、`setEnabled`、`setGroups`、`checkNow`、`listEvents`、`clearEvents`、`markRead`。事件是群成员差异事实,包含 roomId、member wxid/name、previous/current count、detectedAt;monitor state 中 `events` 只是最近 500 条快照,`totalEventCount` 对应完整内存历史。
|
||||
|
||||
`AutomationService.handleGroupExit` 只处理 `BUILTIN_LEAVE_NOTIFICATION_RULE_ID` 对应的 singleton 规则。规则另有 `notifyScope` / `notifyRoomIds` 二次范围、target、template。Agent 配“监控 A/B/C”需改 monitor 范围;配置通知目标/通知哪些被监控群则另改这条 leave notification automation。两者不能合并为 `/monitors/{id}/notify`。
|
||||
|
||||
### C. Automation 与 Scheduled Report
|
||||
|
||||
`AutomationRuleStore` 的真实方法有 `listRules/getRule/createRule/updateRule/saveLeaveNotificationRule/deleteRule/setRuleEnabled`;`AutomationExecutionLogService` 提供 `list/record/clear/countSince`。执行日志最多留存 200 条,clear 属于破坏性操作。规则在 `{userData}/automation/rules.json` 中 JSON 持久化。
|
||||
|
||||
`scheduled-report-service.ts` 的调度来源是 `automationRuleStore.listRules()`,执行交给 `AutomationService.executeScheduledRule()`,execution 从 Automation Log 投影。迁移后的旧 `tasks.json`/`executions.json` 是只读历史存档。旧 HTTP API 通过 `ScheduledReportApiService` 转换旧 DTO;创建、修改、删除、启停最终也是读写 `AutomationRuleStore`。所以正确方案是保留兼容 facade,并建立 Automation canonical API,不要继续增加第二个 scheduled-report store。
|
||||
|
||||
旧 facade 的限制:只列/操作可投影为 `target.type === 'wechat_group'`、且目标等于来源群的规则。如今 scheduled automation 支持 source_chat/self/file_transfer/contact,故通过新 UI 创建为文件传输助手或联系人目标的规则,会从旧 `/scheduled-reports` 列表隐藏。旧 API 输入 schema 也不能表示完整 scheduled config(成员名、消息类型、模板、timeout、postfix 等)。
|
||||
|
||||
写 API 前还需处理 `AutomationRuleStore` 的归一化和持久化契约:未知 ruleType 会降成 `daily_report`,大部分错误枚举会静默落安全默认;缺省 enabled 是 true;`persist()` catch 写盘错误后只记 warning,Store 仍返回创建/更新后的对象。HTTP adapter 必须先 strict validate,且 Store 需要可观察的持久化结果,不能把内存态冒充成功。
|
||||
|
||||
### D. Group Analytics 确认项
|
||||
|
||||
`GroupStatsService.getMemberStats` 已有可直接复用的核心计算;接口具体有:
|
||||
|
||||
- 当前群成员:`memberCount`、`activeMembers`、`silentMembers`、各活跃成员 `messageCount`/`lastMessageTime`;
|
||||
- 查询窗口:`startTime`、`endTime`(epoch ms)、`firstMessageTime`;
|
||||
- 数据完整性:`freshness = fresh|stale|unknown`、`complete`、`limitations`;
|
||||
- 诊断:`unattributedMessages`、`excludedSystemMessages`。
|
||||
|
||||
成员名单是当前成员集合;知识库统计的 sender 不在当前集合时被排除,只在 `limitations` 中增加“另有 N 位窗口内发言者已不在当前群成员名单”。Service 不返回其身份/每人消息数,也没有 `totalMessageCount`。若 Agent 需要“前成员榜”或全群消息数,需要先扩展 Service/shared type;不能由 API adapter 从 limitation 文案反解析。
|
||||
|
||||
## 4. API / Docs / API Center Drift
|
||||
|
||||
| 项目 | 代码事实 | 漂移/影响 |
|
||||
|---|---|---|
|
||||
| HTTP、共享定义与界面列表 | HTTP 有 30 个 method/path 模板;`LOCAL_API_ENDPOINTS` 定义 14 项,Renderer `API_ENDPOINTS` 实际展示 12 项 | 16 个 HTTP 操作模板没有共享定义;另有 2 个已定义项(个人微信能力、定时日报列表)没有展示。界面仅呈现 12/30 项,不能作为完整 API catalog |
|
||||
| Scheduled Report 展示 | 共享定义只有 `GET /scheduled-reports`;该项本身也未进入 Renderer 列表 | POST 和 task action 不显示,GET 列表也不显示;Agent 在 API Center 里无法发现这组 API |
|
||||
| WeChat Capability 展示 | 共享定义有 `GET /wechat-personal/send-capability`;Renderer 列表未包含它 | API Center 看不到个人微信发送能力状态,用户可能误把 Agent Hub 状态当成完整发送能力 |
|
||||
| Query 展示 | Query 文档在 `api.md` 的独立 LLM-friendly 章节,Service/HTTP 实现完整 | API Center 看不到;用户可能误认为 Reader API 仍只有旧 chatlog |
|
||||
| Media 方法 | `/media/{mediaId}` 支持 GET、HEAD | 文档仅列 GET;API Center 都未列 |
|
||||
| Retry Send | 文档表称 retry-send“复用已有 PNG 重试发送” | `ScheduledReportApiService.retrySend()` 当前无条件抛 `501 not_supported`;integration/unit tests 也未覆盖 retry 路由的这项现状 |
|
||||
| Scheduled Report 完整性 | 旧 facade 只 project `source_chat` | UI 可保存的其他 scheduled target 会从旧 API list/get 隐藏;不是两份存储,但旧 API 不是 Automation API 的完整别名 |
|
||||
| Health 版本 | `/health` 固定返回 `version: "1.0.0"` | 与当前 package version `2.5.0` 不同,Agent 无法据此判断应用版本 |
|
||||
| HTTP 动词 | 九个静态 GET 语义路由无 method guard | POST/PATCH/DELETE 等也可能调用读取逻辑;`/health` 任意 method 均免 Token。测试目前未锁定统一 405 契约 |
|
||||
| 请求/错误 schema | JSON parsing 和错误形状分散:通用 `sendError`、ScheduledReport 专用 error、Query status body、业务自身 result | Agent 要写多套解析逻辑;共享 API schema 和统一错误 code 不存在 |
|
||||
| 命名 | `/contact`、`/chatroom`、`/recent_chat`、`/group_snapshot` 与 `/scheduled-reports`、`/query/*`、`/agent/*`、`/wechat-personal/*` 并存 | snake_case 旧路径、资源路径和“Agent 为业务 owner”的命名混杂;新接口不能继续沿用此漂移 |
|
||||
|
||||
文档 [api.md](../agent/api.md#L35) 基本列出当前 HTTP 路径,Query 在后续单独说明;除 HEAD 外没有发现漏写的当前业务路径,但 retry-send 的成功语义过期。API Center 的来源是单独的 [local-api-test.ts](../../src/shared/local-api-test.ts) 和 [apiEndpoints.ts](../../src/renderer/src/features/api-center/model/apiEndpoints.ts),没有从 HTTP route/schema 派生。测试现有 `local-api-auth` 覆盖鉴权、媒体、部分 Query 和 Agent send;`scheduled-report-api` 覆盖旧生命周期;`local-api-contact-search` 覆盖 hydrate。它们没有自动比对 HTTP route、文档、Catalog 三者,也没有覆盖全部 method guard 和 retry-send。
|
||||
|
||||
## 5. Candidate API Matrix
|
||||
|
||||
风险按本任务口径:R0 只读;R1 本地配置/应用状态修改;R2 微信发送、AI/provider 调用等外部副作用;R3 删除或清空不可轻易恢复的数据。R1 不代表没有后续行为:enable 一条定时规则会武装未来的 R2 执行。
|
||||
|
||||
| Capability | 当前实现 | 当前 API | 建议 | Agent 用例 | Risk | Priority |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 联系人/群/会话 resolve | Chat Service + Contact Resolution | 有旧 routes;Query 内 resolve | 保留旧路由;新 resource 返回稳定 ID、歧义候选 | 查找群并取得 roomId | R0 | P0(复用) |
|
||||
| 结构化消息/搜索/上下文/概览 | LocalQueryApiService + Knowledge | `/query/*` | 保持契约;加 route schema/catalog,后续可升级稳定 ID | 查聊天、关键词/OCR、补上下文 | R0 | P0(复用) |
|
||||
| 应用 capability discovery | 各 Service 能回答局部状态 | 无;`query/capabilities` 仅 Query Tools | 新 `GET /capabilities`,区分 supported/available/reason/operations | 发现自动化、监控、统计、发送 transport | R0 | P0 |
|
||||
| Group Exit Monitor 状态/范围 | GroupExitMonitorService | 仅 IPC | GET state + PATCH enabled/roomIds | 查看监控、监控/停止一个群 | R0/R1 | P0 |
|
||||
| Group Exit events | Monitor JSONL + listEvents | 仅 IPC | GET 带 stable roomId/time/cursor/limit | 最近 7 天谁退群 | R0 | P0 |
|
||||
| 手动检查退群 | checkNow 会扫描并触发事件 handler | 仅 IPC | 有外部通知时按 R2 操作开放,先 validate effects + confirm | 立即检查一次 | R2 | P1 |
|
||||
| Automation 规则 CRUD | AutomationRuleStore | 通用 IPC;HTTP 仅旧 scheduled facade | typed union CRUD;create disabled;validate 再 enable;保护 singleton/system rules | 创建、列出、修改、暂停自动化 | R1/R3(delete) | P0 |
|
||||
| Automation validation/dry-run | 现有编辑器 preview 分散;无通用 validator API | 无 | `POST /automations/validate`;不落盘、不发送 | 确认群、目标、模板、下次运行和能力 | R0 | P0 |
|
||||
| Automation execution history | AutomationExecutionLogService,最多 200 条 | schedule 专属旧投影 | 规范化 Automation execution 读接口;清日志不开放第一批 | 查看失败、按 rule 过滤 | R0/R3(clear) | P0 |
|
||||
| Group member stats | GroupStatsService | 仅 IPC | 按稳定 group ID + ISO window 读统计;先补 former/total 语义 | 近 30 天活跃榜 | R0 | P1 |
|
||||
| WeChat capability | personal capability service;Agent Hub status | personal GET + Agent status | 新全局 capability 含 personal/iLink 和内容能力;旧路由保留 | 检查发送当前是否可用 | R0 | P0 |
|
||||
| 手动微信发送 | WechatSendGateway + Action Gateway | `/agent/send` iLink test send | 新 send command 经受限 Action facade,强制 stable recipient、confirm、idempotency | 文件助手测试消息 | R2 | P1 |
|
||||
| Send Log / Action audit | Send Log 500 条;Action audit 500 条;IPC action-log | 无 HTTP | 分层只读分页,preview 脱敏;按 executionId/requestId 关联 | 查最近发送失败、审计规则动作 | R0 | P1 |
|
||||
| Agent Hub status | AgentHubService.getStatus | `/agent/status` + IPC | 保留 alias,新资源名归 `/agent-hub/status`,与 app capabilities 分开 | 查 Hub/connector online | R0 | P1(复用) |
|
||||
| Agent Hub 对话内容 | Conversation Store,最多 50 会话×500 条 | 仅 IPC | 默认为 Internal;若产品确认需要,另做显式 opt-in、分页/时间过滤 | 查看机器人与某人的对话 | R0(高隐私) | 不建议第一批 |
|
||||
| AI 群日报 | AgentGroupReportService + export | `/agent/group-report` | 保留兼容;未来先 validate model/range/group/data egress,再异步 job | 生成临时总结图片 | R2(provider/本地文件) | P1 |
|
||||
| 日报历史 | ReportHistory Service,IPC CRUD | 无 | 分页 metadata DTO;图片 asset 单独下载;不返回 base64/路径/完整 snapshot | 昨天生成过哪些日报 | R0 | P1 |
|
||||
| 模板列表 | Template Service + market catalog | 仅 IPC | 仅已安装模板只读列表列 P2 | 有哪些日报模板 | R0 | P2 |
|
||||
| 模板安装/删除/历史改版 | Template Service/Market + Report History | 仅 IPC | 不开放通用 HTML/路径写入;将来单独授权且保留校验 | 安装或修改模板 | R1/R3 | Maybe/P2 |
|
||||
| Recall archive 查询 | 内部 archive merge 到历史消息 | 无独立 API | 不单独开放磁盘 Archive;给 Query 增 `recalled` 来源标记 | 找被撤回消息 | R0(敏感/语义风险) | P2 |
|
||||
| Image OCR index 操作 | image-text-index service | IPC(含 clear/repair) | coverage 状态可汇入 capabilities/status;不让 Agent 操作 clear/reset | 查 OCR 覆盖 | R0/R1/R3 | P2 |
|
||||
| AI image insight | ImageInsightService 读/解密图片并调 vision provider | IPC | 不暴露任意 hash/message AI 分析,除非有成本/隐私授权 | 理解群图片 | R2 | 不建议第一批 |
|
||||
| DB key、根目录、settings、cache | 多个设置/DB/cache Service | IPC/UI | 禁止通用 settings patch / 文件路径 / DB key API;只加白名单状态字段 | 修改本机数据库、安全设置 | R1/R3 | 不建议开放 |
|
||||
| Connector 生命周期/inbox | AgentHubService + WechatInboundInbox | 仅 IPC/内部 | connector 登录、验证码、QR、inbox、context token 不对 Agent 暴露 | 重连或直接拿入站 token | R1/R2 | 不建议开放 |
|
||||
|
||||
## 6. P0 Recommendation
|
||||
|
||||
第一批目标是“让 Agent 能配置和核验 TraceMemo,但不意外发消息”。建议只包括:
|
||||
|
||||
1. `GET /api/v1/capabilities`:应用级 capability,不与现有 `/query/capabilities` 合并。返回版本、DB/readiness、supported vs available、不可用原因和依赖;发送分 personal/iLink 与 text/image/voice 能力。
|
||||
2. Group Exit Monitor:读取状态、显式配置 monitored roomIds、读取历史事件。`PATCH` 只接 canonical 群 ID,拒绝不存在/非群 ID;修改范围响应明确显示后台 baseline/check 状态。`check` 先列 P1,因为它可能启动退群通知发送。
|
||||
3. Automation typed CRUD:列/读规则、创建 disabled 规则、更新、显式启停、执行历史读取。Leave Notification 仍使用固定 singleton id,不允许创建重复规则;拒绝未知字段/未知 enum,而不是靠 `normalizeRuleDraft` 静默修正。
|
||||
4. `POST /automations/validate`:验证目标群、稳定通知 recipient、模板变量、report/template 配置、send capability 和 nextRunAt;只返回 plan,不落盘、不发送。
|
||||
5. 运行状态与错误契约:一致的 405、最大 body、请求 ID、错误 envelope;这是任何新 Agent 写接口前的 foundation,不是大规模权限系统。
|
||||
|
||||
Agent 实现示例(概念流程):
|
||||
|
||||
- “监控 A/B/C 退群”:resolve 三个群为 roomId → validate scope → PATCH monitor group IDs。
|
||||
- “A 群有人退出就通知文件助手”:读取 monitor scope 和 singleton leave rule → validate 类型/notify scope/target → 更新规则但保持 disabled → 用户/Agent 明确 enable。监控与 leave-notification 是两份正交配置。
|
||||
- “每天 20:00 生成产品群日报”:resolve sourceConversationId → validate scheduled rule(包含 target/transport/模板/时区/next run)→ 创建 disabled → 显式 enable。旧 `/scheduled-reports` 无法表达所有当前 config,不承担新 Agent CRUD。
|
||||
- “昨天哪些自动化失败”:读 automation executions,按本机 timezone/UTC offset 和 status 查询,不清理日志。
|
||||
|
||||
## 7. Proposed Resource Model
|
||||
|
||||
采用业务 capability 资源,HTTP server 只负责 transport、auth、body、统一错误;每个 domain route 调用独立的 main-process API facade/Service adapter。Facade 复用当前 Service/Store,不把 UI IPC 当 HTTP RPC 转发层。
|
||||
|
||||
| Resource | 职责 | 现有路径的处理 |
|
||||
|---|---|---|
|
||||
| `system` | health、版本、应用级 capabilities、运行状态 | `/health` 保留;新增 `/capabilities`;`query/capabilities` 不改语义 |
|
||||
| `contacts` / `groups` | 稳定 ID 列表、resolve、群成员快照/统计 | `/contact`、`/chatroom`、`/resolve`、`/group_snapshot` 保留兼容 |
|
||||
| `query` | 消息/搜索/上下文/概览证据 | 现有 `/query/*` 保持;query ID 口径升级需兼容 reader skill |
|
||||
| `monitors` | 退群监控范围、启停、事件、显式 check | 新 `/monitors/group-exits`,与通知规则分离 |
|
||||
| `automations` | 规则 typed CRUD、validate、启停和运行 | 新 `/automations` 是 canonical HTTP resource;底层仍由 `AutomationRuleStore` 存储 |
|
||||
| `executions` | 跨 Automation/Action/Send 的只读运行视图 | 新 `/executions` read model,不合并各自写存储 |
|
||||
| `wechat` | transport capabilities、受控 send command、Send Log/Action audit | `/agent/send` 与 `/wechat-personal/send-capability` 保留兼容 |
|
||||
| `reports` | report history 元数据/asset;installed templates read-only | `/report` 与 `/agent/group-report` 保留为不同兼容操作 |
|
||||
| `agent-hub` | Hub/connector 状态;conversation API 默认 internal | `/agent/status` 保留 alias,不复用 app capability |
|
||||
| `developer` | 高级 raw request tester 与诊断 | API Center 的 tester;不作为 Agent 业务 API |
|
||||
|
||||
共享资源原则:新 API 输入以 stable id 为主、名字只用于 resolve;所有写请求 strict validate;时间对新资源用 offset ISO-8601;分页使用 `limit` + `cursor`;成功/失败使用一个 typed envelope;不直接返回 app userData path、token、context token、AES key 或原始 transport payload。
|
||||
|
||||
## 8. Safety Model
|
||||
|
||||
### 当前边界
|
||||
|
||||
- 默认监听 `127.0.0.1:6131`,但 host/port 由设置和 `api:start` 调用传入,API Center 会警告非 loopback。CORS 只允许 loopback Origin,但不带 Origin 的 curl/Agent 请求仍可用 Token;CORS 不是本地进程授权边界。
|
||||
- `/health` 公开,其余 endpoint 共用单一 Bearer Token。Token 为 32 random bytes、safeStorage 加密存储并设 `0600`,没有 read/config/send scope。拿到 token 即可读取聊天,也能建/删/启停规则、立即发送。
|
||||
- HTTP `/agent/send` 的消息进入 `WechatSendGateway`,因此有低层 Send Log;但没有 `WechatActionGateway` 的业务 Action audit/策略决策,也没有调用方 Idempotency-Key。Personal capability 路由只报告个人微信状态。
|
||||
- `WechatActionGateway` 的请求包含 `purpose`、`triggerType`、recipient、content、`idempotencyKey`、`executionId`;Automation trigger 有 purpose allowlist,sender capability 会先检查,审计记录会保存 content preview/hash。当前 `evaluateWechatActionPolicy` 对 `triggerType=user` 不做 purpose allowlist,`shouldUseAiPolicy` 固定 false;幂等只对显式 key 或历史特定 scheduled request 生效。它目前只发个人微信,不能直接替换 iLink send。
|
||||
- Action Audit 和 Send Log 各自上限 500;Automation Execution Log 上限 200。三类日志粒度不同,不是重复的同一事实。
|
||||
|
||||
### 分级建议
|
||||
|
||||
| 风险 | 操作 | 建议控制 |
|
||||
|---|---|---|
|
||||
| R0 | 查询聊天/成员/事件/状态/统计/日志/报告元数据 | 保留本地 Token;响应明确 scope、coverage、freshness、隐私字段 |
|
||||
| R1 | 修改监控群、Automation 配置、启停、模板设置 | typed validation、dry-run plan、显示持久化成功;enable 需确认它武装未来发送 |
|
||||
| R2 | 微信发送、立即执行日报/退群通知 check、调用 AI Provider 生成报告 | 明确 recipient 和内容/规则;`Idempotency-Key` 必填;统一 Action policy + Action audit + Send Log;重复请求回放原结果;返回发送状态 |
|
||||
| R3 | 清理退群事件、删除 report、清空执行/发送/Action 日志、清 cache/index、删规则 | 初始不开放;如以后开放,独立权限、预览计数、可恢复备份/本地确认,不接受批量 wildcard |
|
||||
|
||||
现有 Bearer Token 不足以支撑“查询、配置、发送、清理”都对一个不受信 Agent 开放。近期不必造完整账户系统,但应先修路由 method/body/validation,提供默认只读或 disabled 配置工作流;后续可加入多个 named token + scope(`read`, `configure`, `send`, `destructive`),R2 请求确认和 durable idempotency。风险分类也需承认本地 artifact write(如 `/report` 导出)不是配置本身,可先按 R1 local-write 处理。
|
||||
|
||||
## 9. Compatibility Plan
|
||||
|
||||
1. 不 rename/remove 任何现有 route。Reader Skill 依赖旧 contacts/chatlog/media 路径;新 structured query 已经是更合适的 Agent query,但两者并行。
|
||||
2. 新 `/automations` 读写同一 `AutomationRuleStore`。旧 `/scheduled-reports*` 改为明确标注 Deprecated 的 compatibility adapter;维持现有 request/response shape 和 source_chat 子集,不维护第二个任务存储。响应可加 `Deprecation` header/文档说明,未定 sunset 前不返回 breaking error。
|
||||
3. `/scheduled-reports` 的投影不能假装覆盖所有 scheduled automation。旧 list/get 只反映 source_chat;新 clients 必须迁到 `/automations?type=scheduled_report`。旧 `retry-send` 保留返回 501,文档明确废弃;不能伪造已发送成功。
|
||||
4. `/agent/send` 继续表示现有 iLink/Agent Hub 测试发送。新 `/wechat/send` 必须先明确 transport/recipient schema,再通过能统一 personal+iLink 的受控 Action facade;如果不能保留旧发送语义,就将旧路径作为 adapter 而不是简单 alias。
|
||||
5. `/wechat-personal/send-capability` 保持 personal-only 兼容 view;新全局 capability 返回 transport map。应用能力 `/capabilities` 和 Query Tool 的 `/query/capabilities` 各自有清晰不同的契约。
|
||||
6. 同一 shared contract/catalog 应供 HTTP 验证、API Center、文档和 route contract tests 使用;把实际路由、文档和 API Center 三者 drift 变成测试失败,而不是发布后人工发现。
|
||||
|
||||
## 10. Proposed Phase Plan
|
||||
|
||||
### Phase A — HTTP Contract / Facade
|
||||
|
||||
给现有 routes 加明确 method guard、body size limit、严格 shared request schema、统一错误 envelope/request id;补 `AutomationRuleStore` 写盘成功/失败结果;建立稳定 conversation ID adapter 和 route contract tests。保留 raw Node HTTP,不需要为第一阶段换 web framework。
|
||||
|
||||
### Phase B — Read + Validation P0
|
||||
|
||||
增加应用 `/capabilities`、monitor state/events、automation rules/executions 读取、`/automations/validate`、group stats adapter。monitor `check` 因可能触发 notification 暂留 P1 或先加 side-effect confirmation。把新资源路径、schema、风险元数据接进 EndpointCatalog/生成文档。
|
||||
|
||||
### Phase C — Automation Configuration
|
||||
|
||||
开放 disabled create、PATCH、启停和 DELETE 防护;退群通知专用 singleton upsert 接入同一规则资源;scheduled report 使用真实完整 config;旧 scheduled API 只做 compatibility projection。先做 dry-run 再允许 enable。
|
||||
|
||||
### Phase D — Side Effects / Execution Read Model
|
||||
|
||||
扩展一层统一 `Action` facade 支持 iLink + personal transports,并有 per-purpose policy、recipient allowlist/validation、确认语义、强幂等、Action audit 到 Send Log correlation。再开放 send/check/run;按需增加 `/executions` read projection,不合并底层日志存储。
|
||||
|
||||
### Phase E — API Center
|
||||
|
||||
Overview、Query、Monitors、Automations、WeChat Actions、Reports、Developer 分区;按业务任务做 schema-aware 表单/效果预览,Raw Request Tester 留在 Developer。展示 Token scope/host 范围/transport capability,而不只是固定 URL 测试器。
|
||||
|
||||
## 11. Concrete Endpoint Proposal
|
||||
|
||||
下表是下一阶段建议契约,不表示当前已实现。新写 API 应统一错误:`{"error":{"code":"...","message":"...","details":{...}},"requestId":"..."}`。时间使用带 offset 的 ISO-8601;接口只接受 stable IDs。
|
||||
|
||||
| Method | Path | Request → Response | Risk | Underlying Service |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/v1/capabilities` | 无 → app version/readiness + `query`,`groups.memberStats`,`groupExitMonitor`,`automations`、每种 `wechat.transport/content` 的 `supported/available/reason` | R0 | 新薄 facade 汇总 LocalQuery、GroupStats、Monitor、AutomationStore、personal capability、AgentHub status |
|
||||
| GET | `/api/v1/monitors/group-exits` | 无 → `{enabled,running,monitoredConversationIds,lastCheckedAt,eventCount}` | R0 | `GroupExitMonitorService.getState` |
|
||||
| PATCH | `/api/v1/monitors/group-exits` | `{enabled?,monitoredConversationIds?}` → 保存后的 state;监控 ID 必须 resolve 到现有群 | R1 | `setEnabled` / `setMonitoredRoomIds` |
|
||||
| GET | `/api/v1/monitors/group-exits/events?conversationId=&since=&until=&limit=&cursor=` | ISO 时间和稳定群 ID → `{events,nextCursor}`,事件显式 `eventId`、group/member、counts、detectedAt | R0 | `GroupExitMonitorService.listEvents`;为无 cursor 的现有 list 加稳定分页 adapter |
|
||||
| POST | `/api/v1/monitors/group-exits/check` | `{confirmSideEffects:true}` + `Idempotency-Key` → checkedAt、新事件数、notification execution refs | R2 | `checkNow`;因检查可发现事件并调用 leave-notification Automation,不应标成纯读 |
|
||||
| GET | `/api/v1/automations?type=&enabled=` | 无 → typed rules page;包括 singleton leave rule | R0 | `AutomationRuleStore.listRules` |
|
||||
| POST | `/api/v1/automations` | typed `AutomationRuleDraft`,create 默认 `enabled:false` → `{rule}` | R1 | `AutomationRuleStore.createRule`(需先强化 strict validation/persist result) |
|
||||
| GET | `/api/v1/automations/{ruleId}` | 无 → `{rule}` | R0 | `AutomationRuleStore.getRule` |
|
||||
| PATCH | `/api/v1/automations/{ruleId}` | typed partial config → `{rule}`;`ruleType` 不可变 | R1 | `AutomationRuleStore.updateRule` |
|
||||
| DELETE | `/api/v1/automations/{ruleId}` | 无 → `{deletedId}`;默认拒绝 builtin/system singleton 删除 | R3 | `AutomationRuleStore.deleteRule` + protected-id policy |
|
||||
| POST | `/api/v1/automations/validate` | typed draft → `{valid,normalizedDraft,issues,effects,resolvedTargets,nextRunAt,capabilities,validationId}`;不保存、不发送 | R0 | 新 validator adapter:contact resolve、template validator、capability services、schedule pure functions |
|
||||
| POST | `/api/v1/automations/{ruleId}/enable` | `{validationId,confirmFutureEffects:true}` → `{rule}`;validation hash 必须匹配当前规则 | R1(武装未来 R2) | `AutomationRuleStore.setRuleEnabled` + validator |
|
||||
| POST | `/api/v1/automations/{ruleId}/disable` | 无 → `{rule}` | R1 | `AutomationRuleStore.setRuleEnabled` |
|
||||
| POST | `/api/v1/automations/{ruleId}/run` | `{confirmSideEffects:true}` + `Idempotency-Key` → `{execution}` | R2 | `AutomationService.executeScheduledRule`;只对具有 `run` 语义的 rule type 开放 |
|
||||
| GET | `/api/v1/automations/executions?ruleId=&status=&trigger=&since=&until=&limit=&cursor=` | 无 → `{executions,nextCursor}` | R0 | `AutomationExecutionLogService.list`;需加 timestamp/filter/cursor adapter |
|
||||
| GET | `/api/v1/groups/{conversationId}/member-stats?start=&end=` | ISO 时间窗口 → active/silent/counts/freshness/complete/limitations;未来要 former members 则先扩 shared result | R0 | `GroupStatsService.getMemberStats`;adapter 把 canonical roomId 转 md5 |
|
||||
| GET | `/api/v1/wechat/capabilities` | 无 → personal/iLink 分 transport 状态与内容能力 | R0 | `PersonalWechatCapabilityService` + `WechatSendGateway.hasIlinkSender`/Agent Hub connector health |
|
||||
| POST | `/api/v1/wechat/send` | `{recipient:{type,id},content:{type:"text",text},confirm:true}` + 必填 `Idempotency-Key` → `{actionId,status,transport,sendLogRef}` | R2 | 扩展后的 `WechatActionGateway`/统一 Action facade;不得裸调 `WechatSendGateway.send` |
|
||||
| GET | `/api/v1/wechat/send-logs?status=&since=&limit=&cursor=` | 无 → 只读、脱敏 Send Log page | R0 | `WechatSendGateway.listSendLog` / `WechatSendLogService.list` |
|
||||
| GET | `/api/v1/wechat/action-logs?executionId=&status=&since=&limit=&cursor=` | 无 → 业务 Action 审计 page;preview 默认截短/可省略 | R0 | `WechatActionLogService.list` / `WechatActionGateway.listAuditRecords` |
|
||||
| GET | `/api/v1/executions?source=&status=&since=&until=&limit=&cursor=` | 无 → 统一只读 projection,每项保留 source/executionId/trigger/action/status/error/times/correlation IDs | R0 | 组合 `AutomationExecutionLogService`、Action audit、Send Log;不合并其存储 |
|
||||
| GET | `/api/v1/reports/history?groupId=&since=&until=&limit=&cursor=` | 无 → 仅元数据分页,不带 `generatedImage`、本地绝对路径或完整 report snapshot | R0 | `listGeneratedReports` 经 summary adapter,最好先让 Service 支持 metadata-only |
|
||||
| GET | `/api/v1/reports/history/{reportId}/image` | 无 → PNG binary | R0 | Report History 的 file resolver;仅按内部 report id 解析,不接收任意路径 |
|
||||
| GET | `/api/v1/report-templates` | 无 → 已安装模板的 stable id/name/version/available 列表 | R0 | `reportTemplateService.list` |
|
||||
| GET | `/api/v1/agent-hub/status` | 无 → hub/connector/dataApi/dbReady 的脱敏状态 | R0 | `AgentHubService.getStatus`;`/api/v1/agent/status` 保留 alias |
|
||||
|
||||
`POST /wechat/send` 建议先只开放 text + 明确目标;image path/url、voice 和 file 类型各自扩大本机文件/外传风险,需独立 validation/permission,不从现有任意 `msg` 输入自动继承。
|
||||
|
||||
## 12. Do Not Expose
|
||||
|
||||
- 任意数据库 key、图片 AES key、微信登录凭据、iLink `context_token`/bot token、inbound inbox raw items;这些是密钥或未处理消息,不是业务 API。
|
||||
- 通用 `settings:set`、任意 `dbRoot`、数据库 disconnect/reopen、cache 全清、Knowledge/OCR 索引 clear/reset;配置或 destructive blast radius 远高于 Agent automation 管理需求。
|
||||
- Agent Hub QR/login/verify-code/reconnect/disconnect/connector lifecycle。它会影响进程状态/账户登录,也会暴露登录材料。
|
||||
- `WechatInboundInbox` pending/clear/complete/recordFailure 等队列控制。它是 at-least-once 消息交付内部机制,外部 ack 会破坏不丢消息语义。
|
||||
- Agent Hub 完整对话正文默认不暴露。若明确产品需要,独立设计用户授权、最小时间窗口、分页、清理和脱敏;现有 Conversation Store 为本机完整收发留档。
|
||||
- 任意本地 file path、path traversal 类导出/报告资产操作;不要把 IPC 的 file chooser、reveal、delete path 形状直接变 HTTP 参数。
|
||||
- 单图 AI insight/任意 Vision analyze、批量 TTS synthesis、AI Provider 配置/测试和 app update download/install;它们具有费用、敏感图片/文本上传或应用安装影响。
|
||||
- 清除退群、Automation、发送/Action、报告历史或批量发送接口作为 P0。清理类不是“管理配置”的必要前提,应使用 R3 独立权限及本地可恢复流程。
|
||||
- 通用“任意 Automation DAG/任意脚本/任意 purpose”的创建。当前实现只有明确的三类规则和固定动作,不是 workflow engine;扩展功能应有显式 ruleType/schema,而不是暴露内存对象。
|
||||
|
||||
## 13. Open Questions
|
||||
|
||||
以下属于产品边界选择,无法只从代码决定:
|
||||
|
||||
1. 新 Agent API 是否允许同时操作个人微信和 Agent Hub/iLink,还是第一版限定一个 transport?现有 capability 与 send endpoint 分属两套连接状态。
|
||||
2. Agent 是否默认只拿只读 scope;配置和 R2 send 是否要求独立 token/用户确认?当前只有单一 Bearer Token。
|
||||
3. 多账号是否属于本阶段?当前 Query/GroupExit 绑定当前活动数据库账号,Agent Hub 有自己的 connector accountId;没有统一 account-scoped API model。
|
||||
4. Agent Hub 完整对话是否要作为 API capability?它含完整消息正文,和从微信数据库按 query 搜历史是不同隐私边界。
|
||||
|
||||
真实微信 runtime 可用性、平台 hydration 和 transport 能力需在后续实现集成测试中验证;静态代码审计无法替代真实数据库/微信连接的运行时验证。
|
||||
+417
-66
@@ -1,5 +1,6 @@
|
||||
import crypto from 'crypto'
|
||||
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
isReady,
|
||||
listContacts,
|
||||
@@ -28,6 +29,11 @@ import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
|
||||
import { LocalQueryApiService } from './services/local-query-api-service'
|
||||
import { automationRuleStore, AutomationRulePersistenceError } from './services/automation-rule-store'
|
||||
import { automationExecutionLogService } from './services/automation-execution-log-service'
|
||||
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
||||
import { LocalAgentApiError, LocalAgentApiService } from './services/local-agent-api-service'
|
||||
import type { GroupStatsService } from './services/group-stats-service'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
@@ -45,6 +51,11 @@ interface RouteContext {
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE'
|
||||
type RouteHandler = ((ctx: RouteContext) => void | Promise<void>) & {
|
||||
allowedMethods: readonly HttpMethod[]
|
||||
}
|
||||
|
||||
export interface HttpServerOptions {
|
||||
tokenProvider?: () => string | null
|
||||
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
|
||||
@@ -54,21 +65,56 @@ export interface HttpServerOptions {
|
||||
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
|
||||
scheduledReportPlatform?: NodeJS.Platform
|
||||
queryApiService?: LocalQueryApiService
|
||||
agentApiService?: LocalAgentApiService
|
||||
groupStatsService?: Pick<GroupStatsService, 'getMemberStats'>
|
||||
appVersionProvider?: () => string
|
||||
}
|
||||
|
||||
let configuredQueryApiService: LocalQueryApiService | undefined
|
||||
let configuredGroupStatsService: Pick<GroupStatsService, 'getMemberStats'> | undefined
|
||||
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
|
||||
configuredQueryApiService = service
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
export function setLocalGroupStatsService(
|
||||
service: Pick<GroupStatsService, 'getMemberStats'> | undefined
|
||||
): void {
|
||||
configuredGroupStatsService = service
|
||||
}
|
||||
|
||||
const MAX_JSON_BODY_BYTES = 1024 * 1024
|
||||
|
||||
class RequestBodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super('Request body exceeds the maximum size')
|
||||
this.name = 'RequestBodyTooLargeError'
|
||||
}
|
||||
}
|
||||
|
||||
function withMethods(
|
||||
methods: readonly HttpMethod[],
|
||||
handler: (ctx: RouteContext) => void | Promise<void>
|
||||
): RouteHandler {
|
||||
return Object.assign(handler, { allowedMethods: methods })
|
||||
}
|
||||
|
||||
function sendMethodNotAllowed(res: ServerResponse, methods: readonly HttpMethod[]): void {
|
||||
res.setHeader('Allow', methods.join(', '))
|
||||
sendError(res, 405, `请求方法不受支持;允许的方法:${methods.join(', ')}`)
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
const body = JSON.stringify(payload, null, 2)
|
||||
const requestId = String(res.getHeader('X-Request-Id') || '')
|
||||
let responsePayload = payload
|
||||
if (status >= 400 && payload && typeof payload === 'object' && !('requestId' in payload)) {
|
||||
responsePayload = { ...(payload as Record<string, unknown>), requestId }
|
||||
}
|
||||
const body = JSON.stringify(responsePayload, null, 2)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Cache-Control': 'no-store'
|
||||
'Cache-Control': 'no-store',
|
||||
...(requestId ? { 'X-Request-Id': requestId } : {})
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
@@ -91,8 +137,8 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!isAllowedCorsOrigin(origin)) return false
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, PATCH, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -112,6 +158,20 @@ function sendUnauthorized(res: ServerResponse): void {
|
||||
})
|
||||
}
|
||||
|
||||
function sendAgentHttpError(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
): void {
|
||||
const requestId = String(res.getHeader('X-Request-Id') || '')
|
||||
sendJson(res, status, {
|
||||
error: { code, message, ...(details !== undefined ? { details } : {}) },
|
||||
requestId
|
||||
})
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
||||
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||
}
|
||||
@@ -121,7 +181,8 @@ function sendBinary(res: ServerResponse, status: number, result: HttpImageResult
|
||||
'Content-Type': result.mimeType,
|
||||
'Content-Length': result.buffer.length,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Request-Id': String(res.getHeader('X-Request-Id') || '')
|
||||
})
|
||||
res.end(result.buffer)
|
||||
}
|
||||
@@ -136,10 +197,26 @@ function sanitizeChatlogMessage(message: Record<string, unknown>): Record<string
|
||||
return { ...message, contentData: safeContentData }
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
function readBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const contentLength = Number(req.headers['content-length'])
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
req.pause()
|
||||
reject(new RequestBodyTooLargeError())
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
let size = 0
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > maxBytes) {
|
||||
chunks.length = 0
|
||||
req.pause()
|
||||
reject(new RequestBodyTooLargeError())
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
@@ -208,18 +285,26 @@ function parseNumeric(value: string | null, fallback: number): number {
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function getApplicationVersion(): string {
|
||||
try {
|
||||
return typeof app.getVersion === 'function' ? app.getVersion() : 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const routes: Record<string, RouteHandler> = {
|
||||
'/api/v1/health': ({ res }) => {
|
||||
'/api/v1/health': withMethods(['GET'], ({ res }) => {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
ready: isReady(),
|
||||
service: 'TraceMemo Reader',
|
||||
version: '1.0.0',
|
||||
version: getApplicationVersion(),
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/current_time': ({ res }) => {
|
||||
'/api/v1/current_time': withMethods(['GET'], ({ res }) => {
|
||||
const now = new Date()
|
||||
sendJson(res, 200, {
|
||||
time: now.toISOString(),
|
||||
@@ -229,9 +314,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
now.getDate()
|
||||
).padStart(2, '0')}`
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/contact': async ({ res, url }) => {
|
||||
'/api/v1/contact': withMethods(['GET'], async ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const filter = url.searchParams.get('filter') || undefined
|
||||
const type = url.searchParams.get('type') || undefined
|
||||
@@ -243,9 +328,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
contacts = contacts.filter((c) => c.type === type)
|
||||
}
|
||||
sendJson(res, 200, { count: contacts.length, contacts })
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/chatroom': async ({ res, url }) => {
|
||||
'/api/v1/chatroom': withMethods(['GET'], async ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
// Same hydration requirement as /api/v1/contact: group display names are
|
||||
@@ -260,16 +345,16 @@ const routes: Record<string, RouteHandler> = {
|
||||
)
|
||||
}
|
||||
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/recent_chat': ({ res, url }) => {
|
||||
'/api/v1/recent_chat': withMethods(['GET'], ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const limit = parseNumeric(url.searchParams.get('limit'), 50)
|
||||
const items = listRecentChat(limit)
|
||||
sendJson(res, 200, { count: items.length, items })
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/chatlog': ({ res, url }) => {
|
||||
'/api/v1/chatlog': withMethods(['GET'], ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const talker = url.searchParams.get('talker')
|
||||
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
||||
@@ -307,27 +392,27 @@ const routes: Record<string, RouteHandler> = {
|
||||
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
|
||||
)
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/group_snapshot': ({ res, url }) => {
|
||||
'/api/v1/group_snapshot': withMethods(['GET'], ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const md5 = url.searchParams.get('md5')
|
||||
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
||||
const snapshot = getGroupSnapshot(md5)
|
||||
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
|
||||
sendJson(res, 200, snapshot)
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/resolve': ({ res, url }) => {
|
||||
'/api/v1/resolve': withMethods(['GET'], ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const q = url.searchParams.get('q')
|
||||
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
||||
const contact = resolveMd5(q)
|
||||
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
|
||||
sendJson(res, 200, contact)
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/report': async ({ req, res, body }) => {
|
||||
'/api/v1/report': withMethods(['POST'], async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
@@ -354,9 +439,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
}
|
||||
const result = await exportGroupReport(request)
|
||||
sendJson(res, result.success ? 200 : 500, result)
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/agent/group-report': async ({ req, res, body }) => {
|
||||
'/api/v1/agent/group-report': withMethods(['POST'], async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
|
||||
@@ -370,9 +455,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
range: request.range
|
||||
})
|
||||
sendJson(res, result.success ? 200 : 400, result)
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/agent/status': ({ res }) => {
|
||||
'/api/v1/agent/status': withMethods(['GET'], ({ res }) => {
|
||||
const status = agentHubService.getStatus()
|
||||
sendJson(res, 200, {
|
||||
ok: status.hub === 'online' && status.connector === 'online',
|
||||
@@ -382,9 +467,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
databaseReady: status.databaseReady,
|
||||
accountId: status.accountId
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/agent/send': async ({ req, res, body }) => {
|
||||
'/api/v1/agent/send': withMethods(['POST'], async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
let request: { to?: string; text?: string; media_url?: string }
|
||||
try {
|
||||
@@ -398,7 +483,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
mediaUrl: request.media_url
|
||||
})
|
||||
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
|
||||
@@ -448,18 +533,18 @@ function createScheduledReportRoute(
|
||||
api: ScheduledReportApiService
|
||||
): RouteHandler | undefined {
|
||||
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
|
||||
return async ({ req, res }) => {
|
||||
return withMethods(['GET'], async ({ req, res }) => {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
try {
|
||||
sendJson(res, 200, { capability: await api.getCapability() })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === SCHEDULED_REPORTS_ROUTE) {
|
||||
return async ({ req, res, body }) => {
|
||||
return withMethods(['GET', 'POST'], async ({ req, res, body }) => {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const tasks = await api.list()
|
||||
@@ -475,7 +560,7 @@ function createScheduledReportRoute(
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
|
||||
@@ -488,7 +573,7 @@ function createScheduledReportRoute(
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return async ({ req, res }) => {
|
||||
return withMethods(['POST'], async ({ req, res }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
try {
|
||||
const execution = await api.retrySend(executionId)
|
||||
@@ -496,7 +581,7 @@ function createScheduledReportRoute(
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
||||
@@ -510,8 +595,15 @@ function createScheduledReportRoute(
|
||||
return undefined
|
||||
}
|
||||
const action = segments[1]
|
||||
if (action && !['enable', 'disable', 'run', 'executions'].includes(action)) return undefined
|
||||
|
||||
return async ({ req, res, body }) => {
|
||||
const methods: HttpMethod[] = !action
|
||||
? ['GET', 'PATCH', 'DELETE']
|
||||
: action === 'executions'
|
||||
? ['GET']
|
||||
: ['POST']
|
||||
|
||||
return withMethods(methods, async ({ req, res, body }) => {
|
||||
try {
|
||||
if (!action && req.method === 'GET') {
|
||||
sendJson(res, 200, { task: await api.get(taskId) })
|
||||
@@ -551,7 +643,7 @@ function createScheduledReportRoute(
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
||||
@@ -566,42 +658,46 @@ function queryStatusCode(status: string): number {
|
||||
return 400
|
||||
}
|
||||
|
||||
function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined {
|
||||
return async ({ req, res, body }) => {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
||||
if (pathname === '/api/v1/query/capabilities') {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
function createQueryRoute(pathname: string, api: LocalQueryApiService): RouteHandler | undefined {
|
||||
if (pathname === '/api/v1/query/capabilities') {
|
||||
return withMethods(['GET'], ({ res }) => {
|
||||
return sendJson(res, 200, api.capabilities())
|
||||
}
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
let payload: any
|
||||
})
|
||||
}
|
||||
type QueryOperationResult =
|
||||
| Awaited<ReturnType<LocalQueryApiService['messages']>>
|
||||
| Awaited<ReturnType<LocalQueryApiService['search']>>
|
||||
| Awaited<ReturnType<LocalQueryApiService['context']>>
|
||||
| Awaited<ReturnType<LocalQueryApiService['overview']>>
|
||||
const operations: Record<string, (payload: unknown) => Promise<QueryOperationResult>> = {
|
||||
'/api/v1/query/messages': (payload) =>
|
||||
api.messages(payload as Parameters<LocalQueryApiService['messages']>[0]),
|
||||
'/api/v1/query/search': (payload) =>
|
||||
api.search(payload as Parameters<LocalQueryApiService['search']>[0]),
|
||||
'/api/v1/query/message-context': (payload) =>
|
||||
api.context(payload as Parameters<LocalQueryApiService['context']>[0]),
|
||||
'/api/v1/query/conversation-overview': (payload) =>
|
||||
api.overview(payload as Parameters<LocalQueryApiService['overview']>[0])
|
||||
}
|
||||
const operation = operations[pathname]
|
||||
if (!operation) return undefined
|
||||
return withMethods(['POST'], async ({ res, body }) => {
|
||||
let payload: unknown
|
||||
try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') }
|
||||
if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request')
|
||||
try {
|
||||
const result = pathname === '/api/v1/query/messages'
|
||||
? await api.messages(payload)
|
||||
: pathname === '/api/v1/query/search'
|
||||
? await api.search(payload)
|
||||
: pathname === '/api/v1/query/message-context'
|
||||
? await api.context(payload)
|
||||
: pathname === '/api/v1/query/conversation-overview'
|
||||
? await api.overview(payload)
|
||||
: undefined
|
||||
if (!result) return sendError(res, 404, `端点不存在: ${pathname}`)
|
||||
const result = await operation(payload)
|
||||
return sendJson(res, queryStatusCode(result.status), result)
|
||||
} catch (error) {
|
||||
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createMediaRoute(
|
||||
mediaProvider: (messageId: string) => Promise<HttpImageResult>
|
||||
): RouteHandler {
|
||||
return async ({ req, res, url }) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
return sendError(res, 405, '需要 GET 请求')
|
||||
}
|
||||
return withMethods(['GET', 'HEAD'], async ({ req, res, url }) => {
|
||||
const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length)
|
||||
let messageId: string
|
||||
try {
|
||||
@@ -640,7 +736,218 @@ function createMediaRoute(
|
||||
safeError('[HttpServer] media request failed:', error)
|
||||
return sendError(res, 500, '图片读取失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createAgentApiService(options: HttpServerOptions): LocalAgentApiService {
|
||||
const groupStats = options.groupStatsService || configuredGroupStatsService
|
||||
return options.agentApiService || new LocalAgentApiService({
|
||||
automationRuleStore,
|
||||
automationExecutionLogService,
|
||||
listContacts: listContactsAsync,
|
||||
isDatabaseReady: isReady,
|
||||
getVersion: options.appVersionProvider || getApplicationVersion,
|
||||
getPersonalWechatCapability: () =>
|
||||
personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
||||
getAgentHubStatus: () => agentHubService.getStatus(),
|
||||
getGroupExitMonitorState: () => {
|
||||
const state = groupExitMonitorService.getState()
|
||||
return { ...state, monitoredRoomIds: state.monitoredRoomIds || [] }
|
||||
},
|
||||
configureGroupExitMonitor: (configuration) =>
|
||||
groupExitMonitorService.configure(configuration),
|
||||
setGroupExitMonitorRoomIds: (roomIds) => groupExitMonitorService.setMonitoredRoomIds(roomIds),
|
||||
setGroupExitMonitorEnabled: (enabled) => groupExitMonitorService.setEnabled(enabled),
|
||||
listGroupExitMonitorEvents: (query) => groupExitMonitorService.listEvents(query),
|
||||
...(groupStats ? { getGroupMemberStats: (query) => groupStats.getMemberStats(query) } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function sendAgentApiError(res: ServerResponse, error: unknown): void {
|
||||
const requestId = String(res.getHeader('X-Request-Id') || '')
|
||||
if (error instanceof LocalAgentApiError) {
|
||||
sendAgentHttpError(res, error.status, error.code, error.message, error.details)
|
||||
return
|
||||
}
|
||||
if (error instanceof AutomationRulePersistenceError) {
|
||||
sendAgentHttpError(res, 500, 'PERSISTENCE_FAILED', '自动化规则未能保存到本地')
|
||||
return
|
||||
}
|
||||
safeError(`[HttpServer requestId=${requestId}] Agent API request failed:`, error)
|
||||
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
|
||||
}
|
||||
|
||||
async function handleAgentApi<T>(
|
||||
res: ServerResponse,
|
||||
operation: () => Promise<T> | T,
|
||||
respond: (value: T) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
respond(await operation())
|
||||
} catch (error) {
|
||||
sendAgentApiError(res, error)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAgentJson(body: unknown): unknown {
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体不能为空')
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body)
|
||||
} catch {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体 JSON 格式无效')
|
||||
}
|
||||
}
|
||||
|
||||
function createAgentApiRoute(
|
||||
pathname: string,
|
||||
api: LocalAgentApiService
|
||||
): RouteHandler | undefined {
|
||||
if (pathname === '/api/v1/capabilities') {
|
||||
return withMethods(['GET'], ({ res }) =>
|
||||
void handleAgentApi(res, () => api.getCapabilities(), (capabilities) =>
|
||||
sendJson(res, 200, capabilities)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const groupExitMonitorPath = '/api/v1/monitors/group-exits'
|
||||
if (pathname === groupExitMonitorPath) {
|
||||
return withMethods(['GET', 'PATCH'], async ({ req, res, body }) => {
|
||||
if (req.method === 'GET') {
|
||||
await handleAgentApi(res, () => api.getGroupExitMonitorState(), (state) =>
|
||||
sendJson(res, 200, state)
|
||||
)
|
||||
return
|
||||
}
|
||||
await handleAgentApi(res, () => api.updateGroupExitMonitor(parseAgentJson(body)), (state) =>
|
||||
sendJson(res, 200, state)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === `${groupExitMonitorPath}/events`) {
|
||||
return withMethods(['GET'], ({ res, url }) =>
|
||||
void handleAgentApi(res, () => api.listGroupExitMonitorEvents(url.searchParams), (result) =>
|
||||
sendJson(res, 200, result)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const groupStatsPrefix = '/api/v1/groups/'
|
||||
if (pathname.startsWith(groupStatsPrefix)) {
|
||||
const segments = pathname.slice(groupStatsPrefix.length).split('/')
|
||||
if (segments.length === 2 && segments[1] === 'member-stats' && segments[0]) {
|
||||
let conversationId: string
|
||||
try {
|
||||
conversationId = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return withMethods(['GET'], ({ res, url }) =>
|
||||
void handleAgentApi(
|
||||
res,
|
||||
() => api.getGroupMemberStats(conversationId, url.searchParams),
|
||||
(result) => sendJson(res, 200, result)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const automationCollection = '/api/v1/automations'
|
||||
if (pathname === automationCollection) {
|
||||
return withMethods(['GET', 'POST'], async ({ req, res, url, body }) => {
|
||||
if (req.method === 'GET') {
|
||||
await handleAgentApi(
|
||||
res,
|
||||
() => api.listAutomations({
|
||||
type: url.searchParams.get('type'),
|
||||
enabled: url.searchParams.get('enabled')
|
||||
}),
|
||||
(rules) => sendJson(res, 200, { count: rules.length, rules })
|
||||
)
|
||||
return
|
||||
}
|
||||
await handleAgentApi(res, () => api.createAutomation(parseAgentJson(body)), (rule) =>
|
||||
sendJson(res, 201, { created: true, rule })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === `${automationCollection}/validate`) {
|
||||
return withMethods(['POST'], async ({ res, body }) => {
|
||||
await handleAgentApi(res, () => api.validateAutomation(parseAgentJson(body)), (result) =>
|
||||
sendJson(res, 200, result)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === `${automationCollection}/executions`) {
|
||||
return withMethods(['GET'], ({ res, url }) =>
|
||||
void handleAgentApi(res, () => api.listExecutions(url.searchParams), (result) =>
|
||||
sendJson(res, 200, result)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const prefix = `${automationCollection}/`
|
||||
if (!pathname.startsWith(prefix)) return undefined
|
||||
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
||||
if (segments.length < 1 || segments.length > 2) return undefined
|
||||
let id: string
|
||||
try {
|
||||
id = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (!id || id.includes('/') || id.includes('\\')) return undefined
|
||||
const action = segments[1]
|
||||
if (action && action !== 'enable' && action !== 'disable') return undefined
|
||||
|
||||
if (action) {
|
||||
return withMethods(['POST'], ({ res }) =>
|
||||
void handleAgentApi(res, () => api.setAutomationEnabled(id, action === 'enable'), (rule) =>
|
||||
sendJson(res, 200, { updated: true, rule })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return withMethods(['GET', 'PATCH', 'DELETE'], async ({ req, res, body }) => {
|
||||
if (req.method === 'GET') {
|
||||
await handleAgentApi(res, () => api.getAutomation(id), (rule) =>
|
||||
sendJson(res, 200, { rule })
|
||||
)
|
||||
return
|
||||
}
|
||||
if (req.method === 'PATCH') {
|
||||
await handleAgentApi(
|
||||
res,
|
||||
() => api.updateAutomation(id, parseAgentJson(body)),
|
||||
(rule) => sendJson(res, 200, { updated: true, rule })
|
||||
)
|
||||
return
|
||||
}
|
||||
await handleAgentApi(res, () => api.deleteAutomation(id), (result) =>
|
||||
sendJson(res, 200, { deleted: true, ...result })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function requestIdFor(req: IncomingMessage): string {
|
||||
const incoming = req.headers['x-request-id']
|
||||
return typeof incoming === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(incoming)
|
||||
? incoming
|
||||
: crypto.randomUUID()
|
||||
}
|
||||
|
||||
function isAgentApiPath(pathname: string): boolean {
|
||||
return pathname === '/api/v1/capabilities' ||
|
||||
pathname === '/api/v1/monitors/group-exits' ||
|
||||
pathname.startsWith('/api/v1/monitors/group-exits/') ||
|
||||
pathname.startsWith('/api/v1/groups/') ||
|
||||
pathname === '/api/v1/automations' ||
|
||||
pathname.startsWith('/api/v1/automations/')
|
||||
}
|
||||
|
||||
export function startHttpServer(
|
||||
@@ -652,11 +959,19 @@ export function startHttpServer(
|
||||
const mediaProvider = options.mediaProvider || readImageMedia
|
||||
const scheduledReportApi = createScheduledReportApi(options)
|
||||
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
|
||||
const agentApi = createAgentApiService(options)
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
const requestId = requestIdFor(req)
|
||||
res.setHeader('X-Request-Id', requestId)
|
||||
let agentApiRequest = false
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||
agentApiRequest = isAgentApiPath(url.pathname)
|
||||
if (!applyCorsHeaders(req, res)) {
|
||||
if (agentApiRequest) {
|
||||
return sendAgentHttpError(res, 403, 'FORBIDDEN', 'Origin 不允许访问本地 API')
|
||||
}
|
||||
return sendError(res, 403, 'Origin 不允许访问本地 API')
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -665,15 +980,37 @@ export function startHttpServer(
|
||||
}
|
||||
const handler =
|
||||
routes[url.pathname] ||
|
||||
createAgentApiRoute(url.pathname, agentApi) ||
|
||||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
|
||||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
|
||||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX)
|
||||
? createQueryRoute(url.pathname, queryApi)
|
||||
: undefined) ||
|
||||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
|
||||
? createMediaRoute(mediaProvider)
|
||||
: undefined)
|
||||
if (!handler) {
|
||||
if (agentApiRequest) {
|
||||
return sendAgentHttpError(res, 404, 'NOT_FOUND', `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
if (!handler.allowedMethods.includes(req.method as HttpMethod)) {
|
||||
if (agentApiRequest) {
|
||||
res.setHeader('Allow', handler.allowedMethods.join(', '))
|
||||
return sendAgentHttpError(
|
||||
res,
|
||||
405,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
`请求方法不受支持;允许的方法:${handler.allowedMethods.join(', ')}`,
|
||||
{ allowedMethods: handler.allowedMethods }
|
||||
)
|
||||
}
|
||||
return sendMethodNotAllowed(res, handler.allowedMethods)
|
||||
}
|
||||
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
|
||||
if (agentApiRequest) {
|
||||
return sendAgentHttpError(res, 401, 'UNAUTHORIZED', 'Valid API token required')
|
||||
}
|
||||
return sendUnauthorized(res)
|
||||
}
|
||||
let body: string | undefined
|
||||
@@ -683,8 +1020,22 @@ export function startHttpServer(
|
||||
const ctx: RouteContext = { req, res, url, body }
|
||||
await handler(ctx)
|
||||
} catch (error) {
|
||||
safeError('[HttpServer] 请求处理失败:', error)
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
res.setHeader('Connection', 'close')
|
||||
res.shouldKeepAlive = false
|
||||
if (agentApiRequest) {
|
||||
sendAgentHttpError(res, 413, 'PAYLOAD_TOO_LARGE', '请求体不能超过 1 MiB')
|
||||
return
|
||||
}
|
||||
sendError(res, 413, '请求体不能超过 1 MiB')
|
||||
return
|
||||
}
|
||||
safeError(`[HttpServer requestId=${requestId}] 请求处理失败:`, error)
|
||||
if (!res.headersSent) {
|
||||
if (agentApiRequest) {
|
||||
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
|
||||
return
|
||||
}
|
||||
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -76,7 +76,7 @@ import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../
|
||||
import { KeyServiceMac } from './key-service-mac'
|
||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||
import * as chat from './services/chat-service'
|
||||
import { apiServer, setLocalQueryApiService } from './http-server'
|
||||
import { apiServer, setLocalGroupStatsService, setLocalQueryApiService } from './http-server'
|
||||
import { skillResourceService } from './services/skill-resource-service'
|
||||
import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service'
|
||||
import { isWechatRunning } from './services/wechat-process-status'
|
||||
@@ -805,6 +805,7 @@ app.whenReady().then(async () => {
|
||||
// 群员统计复用同一个 Knowledge 实例:它只是「读派生库 + 读成员名单」的编排,
|
||||
// 不持有自己的数据库,也不新建索引。
|
||||
groupStatsService = new GroupStatsService(knowledgeSearchService)
|
||||
setLocalGroupStatsService(groupStatsService)
|
||||
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
|
||||
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
|
||||
localQueryApiService.setImageTextCoverageProvider(() =>
|
||||
|
||||
@@ -50,6 +50,13 @@ const LEGACY_SCHEDULED_TASKS_FILE = 'tasks.json'
|
||||
const SCHEDULED_MIGRATION_BACKUP_FILE = 'scheduled-report-migration-backup.json'
|
||||
const CURRENT_VERSION = 4
|
||||
|
||||
export class AutomationRulePersistenceError extends Error {
|
||||
constructor() {
|
||||
super('自动化规则保存失败')
|
||||
this.name = 'AutomationRulePersistenceError'
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredRules {
|
||||
version: number
|
||||
/** 内置「@我生成日报」是否已经播种过。**即使随后被删除也保持 true**。 */
|
||||
@@ -231,7 +238,8 @@ export class AutomationRuleStore {
|
||||
this.legacyConversationResolver = resolver
|
||||
if (!this.loaded) return
|
||||
if (this.state.scheduledReportMigrated) return
|
||||
if (this.runScheduledReportMigration(this.state)) this.persist()
|
||||
const next = structuredClone(this.state)
|
||||
if (this.runScheduledReportMigration(next)) this.commitState(next)
|
||||
}
|
||||
|
||||
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
|
||||
@@ -274,8 +282,7 @@ export class AutomationRuleStore {
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
this.state.rules = [...this.state.rules, rule]
|
||||
this.persist()
|
||||
this.commitRules([...this.state.rules, rule])
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
@@ -316,8 +323,7 @@ export class AutomationRuleStore {
|
||||
} else {
|
||||
delete rule.leaveNotification
|
||||
}
|
||||
this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
|
||||
this.persist()
|
||||
this.commitRules(this.state.rules.map((item, at) => (at === index ? rule : item)))
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
@@ -346,8 +352,7 @@ export class AutomationRuleStore {
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
this.state.rules = [...this.state.rules, rule]
|
||||
this.persist()
|
||||
this.commitRules([...this.state.rules, rule])
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
@@ -355,9 +360,9 @@ export class AutomationRuleStore {
|
||||
this.ensureLoaded()
|
||||
const key = String(id || '').trim()
|
||||
const before = this.state.rules.length
|
||||
this.state.rules = this.state.rules.filter((rule) => rule.id !== key)
|
||||
if (this.state.rules.length === before) return false
|
||||
this.persist()
|
||||
const rules = this.state.rules.filter((rule) => rule.id !== key)
|
||||
if (rules.length === before) return false
|
||||
this.commitRules(rules)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -394,8 +399,7 @@ export class AutomationRuleStore {
|
||||
},
|
||||
updatedAt: this.now()
|
||||
}
|
||||
this.state.rules = this.state.rules.map((item, at) => (at === index ? next : item))
|
||||
this.persist()
|
||||
this.commitRules(this.state.rules.map((item, at) => (at === index ? next : item)))
|
||||
return structuredClone(next)
|
||||
}
|
||||
|
||||
@@ -428,7 +432,11 @@ export class AutomationRuleStore {
|
||||
this.runScheduledReportMigration(stored)
|
||||
stored.version = CURRENT_VERSION
|
||||
this.state = stored
|
||||
this.persist()
|
||||
try {
|
||||
this.persist()
|
||||
} catch {
|
||||
// Keep the in-memory defaults available; later writes report persistence failures.
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
|
||||
@@ -641,6 +649,22 @@ export class AutomationRuleStore {
|
||||
console.warn(
|
||||
`[Automation] 保存规则失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw new AutomationRulePersistenceError()
|
||||
}
|
||||
}
|
||||
|
||||
private commitRules(rules: AutomationRule[]): void {
|
||||
this.commitState({ ...this.state, rules })
|
||||
}
|
||||
|
||||
private commitState(next: StoredRules): void {
|
||||
const previous = this.state
|
||||
this.state = next
|
||||
try {
|
||||
this.persist()
|
||||
} catch (error) {
|
||||
this.state = previous
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,49 +237,72 @@ class GroupExitMonitorService {
|
||||
* 本服务不再持有任何通知配置。
|
||||
*/
|
||||
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
|
||||
return this.configure({ monitoredRoomIds: roomIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically update the monitor scope and enabled state.
|
||||
*
|
||||
* Agent API PATCH validates the whole request before calling this method. Keeping the
|
||||
* two mutations in one service operation also means persistence and the renderer broadcast
|
||||
* observe one final configuration rather than an intermediate half-patched state.
|
||||
*/
|
||||
async configure(configuration: {
|
||||
monitoredRoomIds?: string[]
|
||||
enabled?: boolean
|
||||
}): Promise<GroupExitMonitorState> {
|
||||
this.ensureLoaded()
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
this.monitorSelectionConfigured = true
|
||||
const nextMonitoredRoomIds = normalizeRoomIds(roomIds)
|
||||
for (const roomId of this.snapshots.keys()) {
|
||||
if (!nextMonitoredRoomIds.has(roomId)) this.snapshots.delete(roomId)
|
||||
const hasScope = configuration.monitoredRoomIds !== undefined
|
||||
const hasEnabled = configuration.enabled !== undefined
|
||||
if (!hasScope && !hasEnabled) return this.getState()
|
||||
if (hasEnabled && !hasScope && this.enabled === configuration.enabled) return this.getState()
|
||||
|
||||
if (hasScope) {
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
this.monitorSelectionConfigured = true
|
||||
const nextMonitoredRoomIds = normalizeRoomIds(configuration.monitoredRoomIds || [])
|
||||
for (const roomId of this.snapshots.keys()) {
|
||||
if (!nextMonitoredRoomIds.has(roomId)) this.snapshots.delete(roomId)
|
||||
}
|
||||
for (const roomId of this.hydrationQueue) {
|
||||
if (!nextMonitoredRoomIds.has(roomId)) this.hydrationQueue.delete(roomId)
|
||||
}
|
||||
this.monitoredRoomIds = nextMonitoredRoomIds
|
||||
this.groupNamesRefreshPending = true
|
||||
this.lastCheckedAt = undefined
|
||||
}
|
||||
for (const roomId of this.hydrationQueue) {
|
||||
if (!nextMonitoredRoomIds.has(roomId)) this.hydrationQueue.delete(roomId)
|
||||
|
||||
if (hasEnabled && this.enabled !== configuration.enabled) {
|
||||
this.enabled = configuration.enabled === true
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
this.checkQueued = false
|
||||
this.hydrationQueue.clear()
|
||||
if (this.changeTimer) clearTimeout(this.changeTimer)
|
||||
this.changeTimer = null
|
||||
|
||||
if (this.enabled) {
|
||||
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
|
||||
this.snapshots.clear()
|
||||
this.lastCheckedAt = undefined
|
||||
this.groupNamesRefreshPending = true
|
||||
}
|
||||
}
|
||||
this.monitoredRoomIds = nextMonitoredRoomIds
|
||||
this.groupNamesRefreshPending = true
|
||||
this.lastCheckedAt = undefined
|
||||
|
||||
this.save()
|
||||
this.broadcast()
|
||||
// 建立基线放到后台,保存配置可以立即返回。
|
||||
if (this.enabled && this.active) void this.check()
|
||||
// 仅修改范围时保持原有的后台基线行为;涉及 enabled 的 PATCH 等待一次检查,
|
||||
// 让调用方拿到的是最终运行状态。
|
||||
if (this.enabled && this.active && chat.isReady()) {
|
||||
if (hasEnabled) await this.check()
|
||||
else void this.check()
|
||||
}
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
|
||||
this.ensureLoaded()
|
||||
if (this.enabled === enabled) return this.getState()
|
||||
|
||||
this.enabled = enabled
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
this.checkQueued = false
|
||||
this.hydrationQueue.clear()
|
||||
if (this.changeTimer) clearTimeout(this.changeTimer)
|
||||
this.changeTimer = null
|
||||
|
||||
if (enabled) {
|
||||
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
|
||||
this.snapshots.clear()
|
||||
this.lastCheckedAt = undefined
|
||||
this.groupNamesRefreshPending = true
|
||||
}
|
||||
this.save()
|
||||
this.broadcast()
|
||||
if (enabled && this.active && chat.isReady()) await this.check()
|
||||
return this.getState()
|
||||
return this.configure({ enabled })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
import {
|
||||
BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
calculateNextRunAt,
|
||||
type AutomationExecution,
|
||||
type AutomationRule,
|
||||
type AutomationRuleDraft,
|
||||
type AutomationRuleType
|
||||
} from '../../shared/automation'
|
||||
import { resolveContact } from './contact-resolution-service'
|
||||
import { validateAutomationDraftShape } from '../../shared/agent-api/automation-validation'
|
||||
import type {
|
||||
AgentAutomationExecution,
|
||||
AgentAutomationValidationIssue,
|
||||
AgentAutomationValidationResult,
|
||||
ApplicationCapabilities
|
||||
} from '../../shared/agent-api/contracts'
|
||||
import type {
|
||||
AgentGroupExitMonitorEvent,
|
||||
AgentGroupExitMonitorState
|
||||
} from '../../shared/agent-api/group-exit-monitor'
|
||||
import type { AgentGroupMemberStats } from '../../shared/agent-api/group-stats'
|
||||
import type { GroupExitMonitorEvent } from '../../shared/group-exit-monitor'
|
||||
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../../shared/group-stats'
|
||||
import type { Contact } from '../../shared/types'
|
||||
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
|
||||
import type { AutomationExecutionLogService } from './automation-execution-log-service'
|
||||
import type { AutomationRuleStore } from './automation-rule-store'
|
||||
|
||||
type AutomationRuleStoreApi = Pick<
|
||||
AutomationRuleStore,
|
||||
'listRules' | 'getRule' | 'createRule' | 'updateRule' | 'deleteRule' | 'setRuleEnabled'
|
||||
>
|
||||
type AutomationExecutionLogApi = Pick<AutomationExecutionLogService, 'list'>
|
||||
type RuleScope = 'any' | 'person' | 'group'
|
||||
|
||||
type GroupExitMonitorStateSource = {
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
monitoredRoomIds?: string[]
|
||||
nativeMonitorActive?: boolean
|
||||
monitoredGroupCount?: number
|
||||
monitorSelectionConfigured?: boolean
|
||||
lastCheckedAt?: number
|
||||
lastReadAt?: number
|
||||
unreadCount?: number
|
||||
totalEventCount?: number
|
||||
events?: GroupExitMonitorEvent[]
|
||||
}
|
||||
|
||||
type GroupExitMonitorConfiguration = {
|
||||
enabled?: boolean
|
||||
monitoredRoomIds?: string[]
|
||||
}
|
||||
|
||||
type GroupExitMonitorEventQuery = {
|
||||
roomId?: string
|
||||
sinceMs?: number
|
||||
untilMs?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export class LocalAgentApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'LocalAgentApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalAgentApiDependencies {
|
||||
automationRuleStore: AutomationRuleStoreApi
|
||||
automationExecutionLogService: AutomationExecutionLogApi
|
||||
listContacts: () => Promise<Contact[]>
|
||||
isDatabaseReady: () => boolean
|
||||
getVersion: () => string
|
||||
getPersonalWechatCapability: () => Promise<PersonalWechatSendCapability>
|
||||
getAgentHubStatus: () => { connector: string }
|
||||
getGroupExitMonitorState: () => GroupExitMonitorStateSource
|
||||
/** Preferred atomic monitor configuration operation. */
|
||||
configureGroupExitMonitor?: (
|
||||
configuration: GroupExitMonitorConfiguration
|
||||
) => Promise<GroupExitMonitorStateSource> | GroupExitMonitorStateSource
|
||||
setGroupExitMonitorRoomIds?: (roomIds: string[]) => Promise<GroupExitMonitorStateSource>
|
||||
setGroupExitMonitorEnabled?: (enabled: boolean) => Promise<GroupExitMonitorStateSource>
|
||||
listGroupExitMonitorEvents?: (query: GroupExitMonitorEventQuery) => GroupExitMonitorEvent[]
|
||||
getGroupMemberStats?: (query: GroupMemberStatsQuery) => Promise<GroupMemberStatsResult>
|
||||
}
|
||||
|
||||
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'scheduled_report', 'leave_notification']
|
||||
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
|
||||
const UPDATE_FIELDS = new Set([
|
||||
'name',
|
||||
'trigger',
|
||||
'scope',
|
||||
'conditions',
|
||||
'actions',
|
||||
'cooldownSeconds',
|
||||
'replyDelaySeconds',
|
||||
'leaveNotification',
|
||||
'scheduledReport'
|
||||
])
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function toDraft(rule: AutomationRule): AutomationRuleDraft {
|
||||
return {
|
||||
name: rule.name,
|
||||
enabled: false,
|
||||
ruleType: rule.ruleType,
|
||||
trigger: rule.trigger,
|
||||
scope: rule.scope,
|
||||
conditions: structuredClone(rule.conditions),
|
||||
actions: structuredClone(rule.actions),
|
||||
cooldownSeconds: rule.cooldownSeconds,
|
||||
replyDelaySeconds: rule.replyDelaySeconds,
|
||||
...(rule.leaveNotification
|
||||
? { leaveNotification: structuredClone(rule.leaveNotification) }
|
||||
: {}),
|
||||
...(rule.scheduledReport ? { scheduledReport: structuredClone(rule.scheduledReport) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function deepMergeDraft(
|
||||
current: AutomationRuleDraft,
|
||||
patch: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...current, ...patch, enabled: false }
|
||||
if (patch.conditions !== undefined) {
|
||||
next.conditions = { ...current.conditions, ...record(patch.conditions) }
|
||||
}
|
||||
if (patch.leaveNotification !== undefined && current.leaveNotification) {
|
||||
const leavePatch = record(patch.leaveNotification) || {}
|
||||
next.leaveNotification = {
|
||||
...current.leaveNotification,
|
||||
...leavePatch,
|
||||
...(leavePatch.target !== undefined ? { target: leavePatch.target } : {})
|
||||
}
|
||||
if (record(leavePatch.target)?.type !== undefined) {
|
||||
delete (next.leaveNotification as Record<string, unknown>).targetNeedsReview
|
||||
}
|
||||
}
|
||||
if (patch.scheduledReport !== undefined && current.scheduledReport) {
|
||||
const schedulePatch = record(patch.scheduledReport) || {}
|
||||
const currentConfig = current.scheduledReport
|
||||
next.scheduledReport = {
|
||||
...currentConfig,
|
||||
...schedulePatch,
|
||||
schedule: { ...currentConfig.schedule, ...record(schedulePatch.schedule) },
|
||||
report: { ...currentConfig.report, ...record(schedulePatch.report) },
|
||||
...(schedulePatch.target !== undefined ? { target: schedulePatch.target } : {})
|
||||
}
|
||||
if (record(schedulePatch.target)?.type !== undefined) {
|
||||
delete (next.scheduledReport as Record<string, unknown>).targetNeedsReview
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function stableId(contact: Contact): string {
|
||||
return contact.type === 'user' ? contact.wxid || contact.m_nsUsrName : contact.m_nsUsrName
|
||||
}
|
||||
|
||||
function toIsoTimestamp(value: number | undefined): string | null {
|
||||
if (!Number.isFinite(value) || Number(value) <= 0) return null
|
||||
const date = new Date(Number(value))
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
export class LocalAgentApiService {
|
||||
private readonly deps: LocalAgentApiDependencies
|
||||
|
||||
constructor(dependencies: LocalAgentApiDependencies) {
|
||||
this.deps = dependencies
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<ApplicationCapabilities> {
|
||||
const databaseReady = this.deps.isDatabaseReady()
|
||||
const hub = this.deps.getAgentHubStatus()
|
||||
const monitor = this.deps.getGroupExitMonitorState()
|
||||
let personal: Pick<
|
||||
PersonalWechatSendCapability,
|
||||
'supported' | 'ready' | 'status' | 'capabilities'
|
||||
>
|
||||
try {
|
||||
personal = await this.deps.getPersonalWechatCapability()
|
||||
} catch {
|
||||
personal = {
|
||||
supported: true,
|
||||
ready: false,
|
||||
status: 'error',
|
||||
capabilities: { text: false, image: false, voice: false }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: this.deps.getVersion(),
|
||||
apiVersion: 'v1',
|
||||
database: { ready: databaseReady },
|
||||
query: {
|
||||
supported: true,
|
||||
available: databaseReady,
|
||||
...(!databaseReady ? { reason: 'database_not_ready' } : {})
|
||||
},
|
||||
automations: {
|
||||
supported: true,
|
||||
available: true,
|
||||
ruleTypes: [...RULE_TYPES],
|
||||
operations: [
|
||||
'list',
|
||||
'get',
|
||||
'create',
|
||||
'update',
|
||||
'validate',
|
||||
'enable',
|
||||
'disable',
|
||||
'delete',
|
||||
'executions'
|
||||
]
|
||||
},
|
||||
groupExitMonitor: {
|
||||
supported: true,
|
||||
available: databaseReady && monitor.running,
|
||||
operations: [
|
||||
'read_state',
|
||||
'configure_scope',
|
||||
'enable',
|
||||
'disable',
|
||||
'list_events'
|
||||
],
|
||||
...(!databaseReady
|
||||
? { reason: 'database_not_ready' }
|
||||
: !monitor.enabled
|
||||
? { reason: 'disabled' }
|
||||
: !monitor.running
|
||||
? { reason: 'not_running' }
|
||||
: {})
|
||||
},
|
||||
groupStats: {
|
||||
supported: true,
|
||||
available: databaseReady,
|
||||
operations: ['member_stats'],
|
||||
...(!databaseReady ? { reason: 'database_not_ready' } : {})
|
||||
},
|
||||
wechat: {
|
||||
personal: {
|
||||
supported: personal.supported,
|
||||
available: personal.ready,
|
||||
status: personal.status,
|
||||
content: { ...personal.capabilities },
|
||||
...(!personal.ready ? { reason: personal.status } : {})
|
||||
},
|
||||
ilink: {
|
||||
supported: true,
|
||||
available: hub.connector === 'online',
|
||||
status: hub.connector,
|
||||
...(hub.connector !== 'online' ? { reason: 'connector_not_online' } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getGroupExitMonitorState(): AgentGroupExitMonitorState {
|
||||
const state = this.deps.getGroupExitMonitorState()
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
running: state.running === true,
|
||||
nativeMonitorActive: state.nativeMonitorActive === true,
|
||||
monitoredConversationIds: [...(state.monitoredRoomIds || [])],
|
||||
monitoredGroupCount: Number.isFinite(state.monitoredGroupCount)
|
||||
? Number(state.monitoredGroupCount)
|
||||
: state.monitoredRoomIds?.length || 0,
|
||||
monitorSelectionConfigured: state.monitorSelectionConfigured !== false,
|
||||
lastCheckedAt: toIsoTimestamp(state.lastCheckedAt),
|
||||
lastReadAt: toIsoTimestamp(state.lastReadAt),
|
||||
eventCount: Number.isFinite(state.totalEventCount)
|
||||
? Number(state.totalEventCount)
|
||||
: state.events?.length || 0,
|
||||
unreadCount: Number.isFinite(state.unreadCount) ? Number(state.unreadCount) : 0
|
||||
}
|
||||
}
|
||||
|
||||
async updateGroupExitMonitor(input: unknown): Promise<AgentGroupExitMonitorState> {
|
||||
const patch = record(input)
|
||||
if (!patch) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体必须是 JSON 对象')
|
||||
}
|
||||
const keys = Object.keys(patch)
|
||||
const allowed = new Set(['enabled', 'monitoredConversationIds'])
|
||||
const unknown = keys.find((key) => !allowed.has(key))
|
||||
if (unknown) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `不支持字段:${unknown}`)
|
||||
}
|
||||
if (keys.length === 0) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '至少提供 enabled 或 monitoredConversationIds')
|
||||
}
|
||||
|
||||
const enabled = patch.enabled
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'enabled 必须是布尔值')
|
||||
}
|
||||
|
||||
let monitoredRoomIds: string[] | undefined
|
||||
if (patch.monitoredConversationIds !== undefined) {
|
||||
if (!Array.isArray(patch.monitoredConversationIds)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'monitoredConversationIds 必须是数组')
|
||||
}
|
||||
monitoredRoomIds = []
|
||||
const seen = new Set<string>()
|
||||
for (const value of patch.monitoredConversationIds) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '监控群 ID 不能为空')
|
||||
}
|
||||
const roomId = value.trim()
|
||||
if (seen.has(roomId)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `监控群 ID 重复:${roomId}`)
|
||||
}
|
||||
if (!roomId.endsWith('@chatroom') || roomId.includes('/') || roomId.includes('\\')) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `无效的群会话 ID:${roomId}`)
|
||||
}
|
||||
seen.add(roomId)
|
||||
monitoredRoomIds.push(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
throw new LocalAgentApiError(409, 'DATABASE_NOT_READY', '数据库未就绪,无法修改退群监控配置')
|
||||
}
|
||||
if (monitoredRoomIds !== undefined) {
|
||||
const contacts = await this.deps.listContacts()
|
||||
const groups = new Set(
|
||||
contacts
|
||||
.filter((contact) => contact.type === 'group')
|
||||
.map((contact) => contact.m_nsUsrName)
|
||||
)
|
||||
const missing = monitoredRoomIds.find((roomId) => !groups.has(roomId))
|
||||
if (missing) {
|
||||
throw new LocalAgentApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
`监控群不存在或不是群联系人:${missing}`,
|
||||
[{ path: 'monitoredConversationIds', code: 'group_not_found', message: missing }]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const configuration: GroupExitMonitorConfiguration = {
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(monitoredRoomIds !== undefined ? { monitoredRoomIds } : {})
|
||||
}
|
||||
let nextState: GroupExitMonitorStateSource | undefined
|
||||
if (this.deps.configureGroupExitMonitor) {
|
||||
nextState = await this.deps.configureGroupExitMonitor(configuration)
|
||||
} else {
|
||||
// Test adapters and older embedders may only expose the two primitive operations.
|
||||
// All validation is complete before this fallback starts mutating state.
|
||||
const previous = this.deps.getGroupExitMonitorState()
|
||||
try {
|
||||
if (monitoredRoomIds !== undefined) {
|
||||
if (!this.deps.setGroupExitMonitorRoomIds) {
|
||||
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群监控配置能力尚未就绪')
|
||||
}
|
||||
nextState = await this.deps.setGroupExitMonitorRoomIds(monitoredRoomIds)
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
if (!this.deps.setGroupExitMonitorEnabled) {
|
||||
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群监控配置能力尚未就绪')
|
||||
}
|
||||
nextState = await this.deps.setGroupExitMonitorEnabled(enabled)
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort rollback for legacy adapters. Production uses the atomic operation above.
|
||||
try {
|
||||
if (monitoredRoomIds !== undefined && this.deps.setGroupExitMonitorRoomIds) {
|
||||
await this.deps.setGroupExitMonitorRoomIds(previous.monitoredRoomIds || [])
|
||||
}
|
||||
if (enabled !== undefined && this.deps.setGroupExitMonitorEnabled) {
|
||||
await this.deps.setGroupExitMonitorEnabled(previous.enabled)
|
||||
}
|
||||
} catch {
|
||||
// Preserve the original error; an adapter that cannot roll back is non-atomic by definition.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return this.toGroupExitMonitorState(nextState || this.deps.getGroupExitMonitorState())
|
||||
}
|
||||
|
||||
listGroupExitMonitorEvents(query: URLSearchParams): {
|
||||
count: number
|
||||
events: AgentGroupExitMonitorEvent[]
|
||||
} {
|
||||
if (!this.deps.listGroupExitMonitorEvents) {
|
||||
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群事件查询能力尚未就绪')
|
||||
}
|
||||
const rawConversationId = query.get('conversationId')
|
||||
let roomId: string | undefined
|
||||
if (rawConversationId !== null) {
|
||||
roomId = rawConversationId.trim()
|
||||
if (!roomId || !roomId.endsWith('@chatroom') || roomId.includes('/') || roomId.includes('\\')) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'conversationId 必须是有效的群会话 ID')
|
||||
}
|
||||
}
|
||||
const since = this.parseDateFilter(query.get('since'), 'since')
|
||||
const until = this.parseDateFilter(query.get('until'), 'until')
|
||||
if (since !== undefined && until !== undefined && since > until) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'since 不能晚于 until')
|
||||
}
|
||||
const rawLimit = query.get('limit')
|
||||
const limit = rawLimit === null ? 50 : Number(rawLimit)
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'limit 必须是 1 到 200 之间的整数')
|
||||
}
|
||||
const events = this.deps
|
||||
.listGroupExitMonitorEvents({ roomId, sinceMs: since, untilMs: until, limit })
|
||||
.slice()
|
||||
.sort((left, right) => left.detectedAt - right.detectedAt)
|
||||
.map((event) => this.toAgentGroupExitEvent(event))
|
||||
return { count: events.length, events }
|
||||
}
|
||||
|
||||
async getGroupMemberStats(
|
||||
conversationId: string,
|
||||
query: URLSearchParams
|
||||
): Promise<AgentGroupMemberStats> {
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
throw new LocalAgentApiError(409, 'DATABASE_NOT_READY', '数据库未就绪,无法查询群员统计')
|
||||
}
|
||||
const normalizedConversationId = conversationId.trim()
|
||||
if (!normalizedConversationId || normalizedConversationId.includes('/') || normalizedConversationId.includes('\\')) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'conversationId 格式无效')
|
||||
}
|
||||
const start = this.parseDateFilter(query.get('start'), 'start')
|
||||
const end = this.parseDateFilter(query.get('end'), 'end')
|
||||
if (start === undefined || end === undefined) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'start 和 end 必须同时提供')
|
||||
}
|
||||
if (start > end) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'start 不能晚于 end')
|
||||
}
|
||||
|
||||
const contacts = await this.deps.listContacts()
|
||||
const matches = contacts.filter((contact) => contact.m_nsUsrName === normalizedConversationId)
|
||||
if (matches.length === 0) {
|
||||
throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到指定会话')
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new LocalAgentApiError(409, 'AMBIGUOUS_CONTACT', 'conversationId 匹配到多个会话')
|
||||
}
|
||||
const contact = matches[0]
|
||||
if (contact.type !== 'group') {
|
||||
throw new LocalAgentApiError(422, 'NOT_GROUP_CONVERSATION', 'conversationId 不是群会话')
|
||||
}
|
||||
if (!contact.m_nsUsrName.endsWith('@chatroom')) {
|
||||
throw new LocalAgentApiError(422, 'NOT_GROUP_CONVERSATION', 'conversationId 不是有效的群会话 ID')
|
||||
}
|
||||
if (!this.deps.getGroupMemberStats) {
|
||||
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '群员统计能力尚未就绪')
|
||||
}
|
||||
|
||||
const result = await this.deps.getGroupMemberStats({
|
||||
userMd5: contact.md5,
|
||||
startTime: start,
|
||||
endTime: end
|
||||
})
|
||||
return this.toAgentGroupMemberStats(contact, result)
|
||||
}
|
||||
|
||||
async listAutomations(filter: {
|
||||
type?: string | null
|
||||
enabled?: string | null
|
||||
}): Promise<unknown[]> {
|
||||
if (filter.type && !RULE_TYPES.includes(filter.type as AutomationRuleType)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'type 不受支持')
|
||||
}
|
||||
let enabled: boolean | undefined
|
||||
if (filter.enabled !== undefined && filter.enabled !== null) {
|
||||
if (filter.enabled !== 'true' && filter.enabled !== 'false') {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'enabled 必须是 true 或 false')
|
||||
}
|
||||
enabled = filter.enabled === 'true'
|
||||
}
|
||||
const rules = this.deps.automationRuleStore
|
||||
.listRules()
|
||||
.filter(
|
||||
(rule) =>
|
||||
(!filter.type || rule.ruleType === filter.type) &&
|
||||
(enabled === undefined || rule.enabled === enabled)
|
||||
)
|
||||
return this.toApiRules(rules)
|
||||
}
|
||||
|
||||
async getAutomation(id: string): Promise<unknown> {
|
||||
const rule = this.deps.automationRuleStore.getRule(id)
|
||||
if (!rule) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
return (await this.toApiRules([rule]))[0]
|
||||
}
|
||||
|
||||
async validateAutomation(input: unknown): Promise<AgentAutomationValidationResult> {
|
||||
const structural = validateAutomationDraftShape(input)
|
||||
if (!structural.valid || !structural.normalized) {
|
||||
return { valid: false, issues: structural.issues }
|
||||
}
|
||||
|
||||
const issues = [...structural.issues]
|
||||
const draft = structuredClone(structural.normalized)
|
||||
const references = await this.resolveDraftReferences(draft, issues)
|
||||
if (issues.length) return { valid: false, issues }
|
||||
|
||||
const config = draft.scheduledReport
|
||||
const nextRunAt = config ? calculateNextRunAt(config.schedule.time) : null
|
||||
const effects = this.describeEffects(draft)
|
||||
return {
|
||||
valid: true,
|
||||
issues: [],
|
||||
normalized: draft,
|
||||
effects,
|
||||
nextRunAt,
|
||||
capabilities: {
|
||||
databaseReady: this.deps.isDatabaseReady(),
|
||||
referencedConversationsResolved: references
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createAutomation(input: unknown): Promise<unknown> {
|
||||
const raw = record(input)
|
||||
if (raw?.ruleType === 'leave_notification') {
|
||||
throw new LocalAgentApiError(409, 'SINGLETON_RULE', '退群通知是系统单例规则,请修改现有规则')
|
||||
}
|
||||
const validation = await this.validateAutomation(input)
|
||||
if (!validation.valid || !validation.normalized) {
|
||||
throw new LocalAgentApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'自动化规则校验失败',
|
||||
validation.issues
|
||||
)
|
||||
}
|
||||
const contacts = await this.deps.listContacts()
|
||||
const storedDraft = this.toStoreDraft(validation.normalized as AutomationRuleDraft, contacts)
|
||||
storedDraft.enabled = false
|
||||
const created = this.deps.automationRuleStore.createRule(storedDraft)
|
||||
return (await this.toApiRules([created]))[0]
|
||||
}
|
||||
|
||||
async updateAutomation(id: string, patchInput: unknown): Promise<unknown> {
|
||||
const current = this.deps.automationRuleStore.getRule(id)
|
||||
if (!current) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
const patch = record(patchInput)
|
||||
if (!patch) throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体必须是 JSON 对象')
|
||||
for (const key of Object.keys(patch)) {
|
||||
if (key === 'ruleType') {
|
||||
throw new LocalAgentApiError(400, 'RULE_TYPE_IMMUTABLE', 'ruleType 不可修改')
|
||||
}
|
||||
if (key === 'id' || key === 'createdAt' || key === 'updatedAt') {
|
||||
throw new LocalAgentApiError(400, 'IMMUTABLE_FIELD', `${key} 不允许由客户端修改`)
|
||||
}
|
||||
if (key === 'enabled') {
|
||||
throw new LocalAgentApiError(400, 'USE_ENABLE_OPERATION', '请使用 enable 或 disable 操作')
|
||||
}
|
||||
if (!UPDATE_FIELDS.has(key)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `不支持字段:${key}`)
|
||||
}
|
||||
}
|
||||
for (const configKey of ['leaveNotification', 'scheduledReport']) {
|
||||
if (Object.hasOwn(record(patch[configKey]) || {}, 'targetNeedsReview')) {
|
||||
throw new LocalAgentApiError(
|
||||
400,
|
||||
'IMMUTABLE_FIELD',
|
||||
`${configKey}.targetNeedsReview 只能通过更新 target 清除`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const apiCurrent = (await this.toApiRules([current]))[0] as AutomationRule & {
|
||||
requiresReview?: boolean
|
||||
}
|
||||
const candidate = deepMergeDraft(toDraft(apiCurrent), patch)
|
||||
const validation = await this.validateAutomation(candidate)
|
||||
if (!validation.valid || !validation.normalized) {
|
||||
throw new LocalAgentApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'自动化规则校验失败',
|
||||
validation.issues
|
||||
)
|
||||
}
|
||||
|
||||
const contacts = await this.deps.listContacts()
|
||||
const storedDraft = this.toStoreDraft(validation.normalized as AutomationRuleDraft, contacts)
|
||||
storedDraft.enabled = current.enabled
|
||||
const updated = this.deps.automationRuleStore.updateRule(id, storedDraft)
|
||||
if (!updated) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
return (await this.toApiRules([updated]))[0]
|
||||
}
|
||||
|
||||
async setAutomationEnabled(id: string, enabled: boolean): Promise<unknown> {
|
||||
const current = this.deps.automationRuleStore.getRule(id)
|
||||
if (!current) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
if (enabled) {
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
throw new LocalAgentApiError(409, 'VALIDATION_FAILED', '数据库未就绪,无法启用自动化规则', [
|
||||
{ path: 'database', code: 'database_not_ready', message: 'TraceMemo 数据库未初始化' }
|
||||
])
|
||||
}
|
||||
const apiCurrent = (await this.toApiRules([current]))[0] as AutomationRule
|
||||
const draft = toDraft(apiCurrent)
|
||||
const validation = await this.validateAutomation(draft)
|
||||
if (!validation.valid) {
|
||||
throw new LocalAgentApiError(
|
||||
409,
|
||||
'VALIDATION_FAILED',
|
||||
'自动化规则校验失败,未启用',
|
||||
validation.issues
|
||||
)
|
||||
}
|
||||
}
|
||||
const updated = this.deps.automationRuleStore.setRuleEnabled(id, enabled)
|
||||
if (!updated) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
return (await this.toApiRules([updated]))[0]
|
||||
}
|
||||
|
||||
async deleteAutomation(id: string): Promise<{ deletedId: string }> {
|
||||
if (id === BUILTIN_DAILY_REPORT_RULE_ID || id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) {
|
||||
throw new LocalAgentApiError(409, 'PROTECTED_RULE', '系统内置自动化规则不能删除')
|
||||
}
|
||||
const rule = this.deps.automationRuleStore.getRule(id)
|
||||
if (!rule) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
if (rule.ruleType === 'leave_notification') {
|
||||
throw new LocalAgentApiError(409, 'PROTECTED_RULE', '退群通知是系统单例规则,不能删除')
|
||||
}
|
||||
const deleted = this.deps.automationRuleStore.deleteRule(id)
|
||||
if (!deleted) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
return { deletedId: id }
|
||||
}
|
||||
|
||||
listExecutions(query: URLSearchParams): {
|
||||
count: number
|
||||
executions: AgentAutomationExecution[]
|
||||
} {
|
||||
const ruleId = query.get('ruleId') || undefined
|
||||
const status = query.get('status') || undefined
|
||||
if (status && !EXECUTION_STATUSES.includes(status as AutomationExecution['status'])) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'status 不受支持')
|
||||
}
|
||||
const since = this.parseDateFilter(query.get('since'), 'since')
|
||||
const until = this.parseDateFilter(query.get('until'), 'until')
|
||||
if (since !== undefined && until !== undefined && since > until) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'since 不能晚于 until')
|
||||
}
|
||||
const rawLimit = query.get('limit')
|
||||
const limit = rawLimit === null ? 50 : Number(rawLimit)
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'limit 必须是 1 到 200 之间的整数')
|
||||
}
|
||||
const rulesById = new Map(
|
||||
this.deps.automationRuleStore.listRules().map((rule) => [rule.id, rule])
|
||||
)
|
||||
const executions = this.deps.automationExecutionLogService
|
||||
.list({ limit: 200 })
|
||||
.filter((item) => !ruleId || item.ruleId === ruleId)
|
||||
.filter((item) => !status || item.status === status)
|
||||
.filter((item) => since === undefined || item.triggerTime >= since)
|
||||
.filter((item) => until === undefined || item.triggerTime <= until)
|
||||
.slice(0, limit)
|
||||
.map((item) => this.toApiExecution(item, rulesById.get(item.ruleId)?.ruleType))
|
||||
return { count: executions.length, executions }
|
||||
}
|
||||
|
||||
private toGroupExitMonitorState(state: GroupExitMonitorStateSource): AgentGroupExitMonitorState {
|
||||
const eventCount = Number.isFinite(state.totalEventCount)
|
||||
? Number(state.totalEventCount)
|
||||
: state.events?.length || 0
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
running: state.running === true,
|
||||
nativeMonitorActive: state.nativeMonitorActive === true,
|
||||
monitoredConversationIds: [...(state.monitoredRoomIds || [])],
|
||||
monitoredGroupCount: Number.isFinite(state.monitoredGroupCount)
|
||||
? Number(state.monitoredGroupCount)
|
||||
: state.monitoredRoomIds?.length || 0,
|
||||
monitorSelectionConfigured: state.monitorSelectionConfigured !== false,
|
||||
lastCheckedAt: toIsoTimestamp(state.lastCheckedAt),
|
||||
lastReadAt: toIsoTimestamp(state.lastReadAt),
|
||||
eventCount,
|
||||
unreadCount: Number.isFinite(state.unreadCount) ? Number(state.unreadCount) : 0
|
||||
}
|
||||
}
|
||||
|
||||
private toAgentGroupExitEvent(event: GroupExitMonitorEvent): AgentGroupExitMonitorEvent {
|
||||
return {
|
||||
eventId: event.id,
|
||||
conversationId: event.roomId,
|
||||
groupName: event.groupName,
|
||||
memberId: event.memberWxid,
|
||||
memberName: event.memberName,
|
||||
wechatName: event.wechatName || '',
|
||||
groupRemark: event.groupRemark || '',
|
||||
contactRemark: event.contactRemark || '',
|
||||
previousCount: event.previousCount,
|
||||
currentCount: event.currentCount,
|
||||
delta: event.delta,
|
||||
message: event.message,
|
||||
detectedAt: new Date(event.detectedAt).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
private toAgentGroupMemberStats(contact: Contact, result: GroupMemberStatsResult): AgentGroupMemberStats {
|
||||
const conversationName =
|
||||
contact.m_nsNickName || contact.remark || contact.wechatNickname || contact.m_nsUsrName
|
||||
return {
|
||||
conversation: { id: contact.m_nsUsrName, name: conversationName },
|
||||
conversationId: contact.m_nsUsrName,
|
||||
range: {
|
||||
start: new Date(result.startTime).toISOString(),
|
||||
end: new Date(result.endTime).toISOString()
|
||||
},
|
||||
memberCount: result.memberCount,
|
||||
activeMemberCount: result.activeMemberCount,
|
||||
silentMemberCount: result.silentMemberCount,
|
||||
activeMembers: result.activeMembers.map((member) => ({
|
||||
memberId: member.senderId,
|
||||
displayName: member.displayName,
|
||||
groupNickname: member.groupNickname,
|
||||
messageCount: member.messageCount,
|
||||
lastMessageAt: toIsoTimestamp(member.lastMessageTime)
|
||||
})),
|
||||
silentMembers: result.silentMembers.map((member) => ({
|
||||
memberId: member.senderId,
|
||||
displayName: member.displayName,
|
||||
groupNickname: member.groupNickname
|
||||
})),
|
||||
freshness: result.freshness,
|
||||
complete: result.complete,
|
||||
limitations: [...result.limitations],
|
||||
unattributedMessages: result.unattributedMessages,
|
||||
excludedSystemMessages: result.excludedSystemMessages,
|
||||
firstMessageAt:
|
||||
toIsoTimestamp(result.firstMessageTime ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private parseDateFilter(value: string | null, name: string): number | undefined {
|
||||
if (value === null) return undefined
|
||||
if (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `${name} 必须是带时区的 ISO-8601 时间`)
|
||||
}
|
||||
const timestamp = Date.parse(value)
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `${name} 不是有效时间`)
|
||||
}
|
||||
return timestamp
|
||||
}
|
||||
|
||||
private toApiExecution(
|
||||
execution: AutomationExecution,
|
||||
ruleType?: AutomationRuleType
|
||||
): AgentAutomationExecution {
|
||||
return {
|
||||
executionId: execution.executionId,
|
||||
ruleId: execution.ruleId,
|
||||
ruleName: execution.ruleName,
|
||||
...(ruleType ? { ruleType } : {}),
|
||||
...(execution.trigger ? { trigger: execution.trigger } : {}),
|
||||
status: execution.status,
|
||||
triggerTime: execution.triggerTime,
|
||||
durationMs: execution.durationMs,
|
||||
sourceDisplayName: execution.sourceDisplayName,
|
||||
startedAt: new Date(execution.triggerTime).toISOString(),
|
||||
finishedAt:
|
||||
execution.status === 'running'
|
||||
? null
|
||||
: new Date(execution.triggerTime + execution.durationMs).toISOString(),
|
||||
...(execution.errorSummary ? { error: execution.errorSummary } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveDraftReferences(
|
||||
draft: AutomationRuleDraft,
|
||||
issues: AgentAutomationValidationIssue[]
|
||||
): Promise<boolean> {
|
||||
const needsContacts =
|
||||
(draft.ruleType === 'daily_report' && draft.conditions.conversationIds.length > 0) ||
|
||||
draft.ruleType === 'scheduled_report' ||
|
||||
(draft.ruleType === 'leave_notification' &&
|
||||
(draft.leaveNotification?.target.type === 'contact' ||
|
||||
(draft.leaveNotification?.notifyRoomIds.length ?? 0) > 0))
|
||||
if (!needsContacts) return true
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
issues.push({
|
||||
path: 'database',
|
||||
code: 'database_not_ready',
|
||||
message: 'TraceMemo 数据库未初始化,无法解析联系人或群聊 ID'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const contacts = await this.deps.listContacts()
|
||||
let resolved = true
|
||||
const resolve = (query: string, scope: RuleScope, path: string): Contact | undefined => {
|
||||
const direct = contacts.filter((contact) => {
|
||||
const identifiers = [
|
||||
contact.md5,
|
||||
contact.m_nsUsrName,
|
||||
contact.wxid,
|
||||
contact.wechatId,
|
||||
contact.alias
|
||||
]
|
||||
return identifiers.some(
|
||||
(identifier) => identifier?.toLocaleLowerCase() === query.toLocaleLowerCase()
|
||||
)
|
||||
})
|
||||
const scopedDirect = direct.filter(
|
||||
(contact) =>
|
||||
scope === 'any' ||
|
||||
(scope === 'person' ? contact.type === 'user' : contact.type === 'group')
|
||||
)
|
||||
if (scopedDirect.length === 1) return scopedDirect[0]
|
||||
|
||||
const result = resolveContact(query, contacts, scope)
|
||||
if (result.matched && result.conversationId) {
|
||||
return contacts.find((contact) => contact.md5 === result.conversationId)
|
||||
}
|
||||
resolved = false
|
||||
issues.push({
|
||||
path,
|
||||
code: result.ambiguous ? 'ambiguous_contact' : 'contact_not_found',
|
||||
message: result.ambiguous
|
||||
? '标识匹配到多个联系人,请使用稳定 ID'
|
||||
: '无法解析到现有联系人或群聊',
|
||||
...(result.ambiguous
|
||||
? {
|
||||
details: {
|
||||
candidates: result.candidates.map((candidate) => {
|
||||
const contact = contacts.find((item) => item.md5 === candidate.conversationId)
|
||||
return contact
|
||||
? { id: stableId(contact), name: candidate.displayName }
|
||||
: { id: candidate.conversationId, name: candidate.displayName }
|
||||
})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (draft.ruleType === 'daily_report') {
|
||||
const scope: RuleScope =
|
||||
draft.scope === 'group' ? 'group' : draft.scope === 'direct' ? 'person' : 'any'
|
||||
const ids: string[] = []
|
||||
for (const [index, query] of draft.conditions.conversationIds.entries()) {
|
||||
const contact = resolve(query, scope, `conditions.conversationIds[${index}]`)
|
||||
if (contact) ids.push(stableId(contact))
|
||||
}
|
||||
draft.conditions.conversationIds = ids
|
||||
}
|
||||
|
||||
if (draft.ruleType === 'scheduled_report' && draft.scheduledReport) {
|
||||
const source = resolve(
|
||||
draft.scheduledReport.report.sourceConversationId,
|
||||
'group',
|
||||
'scheduledReport.report.sourceConversationId'
|
||||
)
|
||||
if (source) draft.scheduledReport.report.sourceConversationId = source.m_nsUsrName
|
||||
if (draft.scheduledReport.target.type === 'contact') {
|
||||
const target = resolve(
|
||||
draft.scheduledReport.target.contactId || '',
|
||||
'person',
|
||||
'scheduledReport.target.contactId'
|
||||
)
|
||||
if (target) draft.scheduledReport.target.contactId = stableId(target)
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.ruleType === 'leave_notification' && draft.leaveNotification) {
|
||||
if (draft.leaveNotification.target.type === 'contact') {
|
||||
const target = resolve(
|
||||
draft.leaveNotification.target.contactId || '',
|
||||
'person',
|
||||
'leaveNotification.target.contactId'
|
||||
)
|
||||
if (target) draft.leaveNotification.target.contactId = stableId(target)
|
||||
}
|
||||
const ids: string[] = []
|
||||
for (const [index, query] of draft.leaveNotification.notifyRoomIds.entries()) {
|
||||
const group = resolve(query, 'group', `leaveNotification.notifyRoomIds[${index}]`)
|
||||
if (group) ids.push(group.m_nsUsrName)
|
||||
}
|
||||
draft.leaveNotification.notifyRoomIds = ids
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private toStoreDraft(draft: AutomationRuleDraft, contacts: Contact[]): AutomationRuleDraft {
|
||||
const stored = structuredClone(draft)
|
||||
if (stored.ruleType === 'daily_report') {
|
||||
stored.conditions.conversationIds = stored.conditions.conversationIds.map(
|
||||
(id) => contacts.find((contact) => stableId(contact) === id)?.md5 || id
|
||||
)
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
private async toApiRules(rules: AutomationRule[]): Promise<unknown[]> {
|
||||
const contacts = await this.deps.listContacts().catch(() => [])
|
||||
return rules.map((rule) => {
|
||||
const copy = structuredClone(rule) as AutomationRule & { requiresReview?: boolean }
|
||||
if (copy.ruleType === 'daily_report') {
|
||||
copy.conditions.conversationIds = copy.conditions.conversationIds.map((id) =>
|
||||
contacts.find((contact) => contact.md5 === id)
|
||||
? stableId(contacts.find((contact) => contact.md5 === id)!)
|
||||
: id
|
||||
)
|
||||
}
|
||||
if (copy.ruleType === 'scheduled_report' && copy.scheduledReport) {
|
||||
copy.requiresReview = copy.scheduledReport.targetNeedsReview === true
|
||||
delete copy.scheduledReport.legacyTarget
|
||||
delete copy.scheduledReport.legacySourceGroup
|
||||
delete copy.scheduledReport.lastRunAt
|
||||
delete copy.scheduledReport.lastScheduledSlot
|
||||
}
|
||||
if (copy.ruleType === 'leave_notification' && copy.leaveNotification) {
|
||||
copy.requiresReview = copy.leaveNotification.targetNeedsReview === true
|
||||
}
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
private describeEffects(draft: AutomationRuleDraft): Record<string, unknown> {
|
||||
if (draft.ruleType === 'scheduled_report' && draft.scheduledReport) {
|
||||
const config = draft.scheduledReport
|
||||
return {
|
||||
generatesReport: true,
|
||||
sendsWechatMessage: true,
|
||||
sourceConversationId: config.report.sourceConversationId,
|
||||
target: config.target
|
||||
}
|
||||
}
|
||||
if (draft.ruleType === 'leave_notification' && draft.leaveNotification) {
|
||||
return {
|
||||
sendsWechatMessage: true,
|
||||
monitorScope: 'configured_in_group_exit_monitor',
|
||||
notificationScope: draft.leaveNotification.notifyScope,
|
||||
notificationRoomIds:
|
||||
draft.leaveNotification.notifyScope === 'selected'
|
||||
? draft.leaveNotification.notifyRoomIds
|
||||
: undefined,
|
||||
target: draft.leaveNotification.target
|
||||
}
|
||||
}
|
||||
return {
|
||||
actions: draft.actions.filter((action) => action.enabled).map((action) => action.type),
|
||||
sendsWechatMessage: draft.actions.some(
|
||||
(action) => action.enabled && action.type === 'sendReportImage'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,21 @@ function parseBody(bodyText: string, contentType?: string): { json?: unknown; bo
|
||||
return { bodyText }
|
||||
}
|
||||
|
||||
function materializeEndpointPath(pathname: string, query: Record<string, string>): string {
|
||||
return pathname.replace('{conversationId}', encodeURIComponent(query.conversationId || ''))
|
||||
}
|
||||
|
||||
function appendQueryParameters(
|
||||
url: URL,
|
||||
endpointPath: string,
|
||||
entries: Array<[string, string]>
|
||||
): void {
|
||||
for (const [key, value] of entries) {
|
||||
if (endpointPath.includes(`{${key}}`)) continue
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLocalApiCurlCommand(payload: unknown): {
|
||||
success: boolean
|
||||
command?: string
|
||||
@@ -71,18 +86,17 @@ export function buildLocalApiCurlCommand(payload: unknown): {
|
||||
const service = apiServer.getState()
|
||||
const targetHost = requestHost(service.host)
|
||||
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
|
||||
const url = new URL(endpoint.path, `http://${hostPart}:${service.port}`)
|
||||
entries.forEach(([key, value]) => {
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
})
|
||||
const endpointPath = materializeEndpointPath(endpoint.path, query as Record<string, string>)
|
||||
const url = new URL(endpointPath, `http://${hostPart}:${service.port}`)
|
||||
appendQueryParameters(url, endpoint.path, entries)
|
||||
const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication()
|
||||
if (endpointId !== 'health' && !token) {
|
||||
return { success: false, error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态' }
|
||||
}
|
||||
const authHeader = token ? ` -H 'Authorization: Bearer ${token}'` : ''
|
||||
const command =
|
||||
endpoint.method === 'POST'
|
||||
? `curl -X POST '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
|
||||
endpoint.method === 'POST' || endpoint.method === 'PATCH'
|
||||
? `curl -X ${endpoint.method} '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
|
||||
: `curl '${url.toString()}'${authHeader}`
|
||||
return { success: true, command }
|
||||
}
|
||||
@@ -110,10 +124,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
const targetHost = requestHost(service.host)
|
||||
const targetPort = service.port
|
||||
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
|
||||
const url = new URL(endpoint.path, `http://${hostPart}:${targetPort}`)
|
||||
entries.forEach(([key, value]) => {
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
})
|
||||
const endpointPath = materializeEndpointPath(endpoint.path, query as Record<string, string>)
|
||||
const url = new URL(endpointPath, `http://${hostPart}:${targetPort}`)
|
||||
appendQueryParameters(url, endpoint.path, entries)
|
||||
|
||||
if (!service.running) {
|
||||
return {
|
||||
@@ -150,7 +163,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
})
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
if (endpoint.method === 'POST') headers['Content-Type'] = 'application/json'
|
||||
if (endpoint.method === 'POST' || endpoint.method === 'PATCH') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const request = http.request(
|
||||
url,
|
||||
@@ -208,7 +223,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
error: error.message
|
||||
})
|
||||
})
|
||||
if (endpoint.method === 'POST') request.write(body)
|
||||
if (endpoint.method === 'POST' || endpoint.method === 'PATCH') request.write(body)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,6 +58,67 @@ export const API_ENDPOINTS: ApiEndpoint[] = [
|
||||
{ key: 'q', label: '待解析标识', required: true, placeholder: '昵称、wxid 或 md5' }
|
||||
]
|
||||
}),
|
||||
endpoint('app-capabilities', {
|
||||
name: '应用能力',
|
||||
description: '查看数据库、Query、Automation 与运行环境的能力状态。'
|
||||
}),
|
||||
endpoint('group-exit-monitor', {
|
||||
name: '退群监控状态',
|
||||
description: '查看监控开关、监控群范围、事件数量和运行状态。'
|
||||
}),
|
||||
endpoint('group-exit-monitor-update', {
|
||||
name: '配置退群监控',
|
||||
description: '设置监控群范围或启用/关闭退群监控,不会修改退群通知 Automation。',
|
||||
body: true
|
||||
}),
|
||||
endpoint('group-exit-events', {
|
||||
name: '退群事件历史',
|
||||
description: '按群和时间窗口查询退群事件,最多返回 200 条。',
|
||||
parameters: [
|
||||
{ key: 'conversationId', label: '群会话 ID', placeholder: 'xxx@chatroom' },
|
||||
{ key: 'since', label: '开始时间', placeholder: '2026-10-01T00:00:00+07:00' },
|
||||
{ key: 'until', label: '结束时间', placeholder: '2026-10-02T23:59:59+07:00' },
|
||||
{ key: 'limit', label: '数量上限', placeholder: '50,最大 200' }
|
||||
]
|
||||
}),
|
||||
endpoint('group-member-stats', {
|
||||
name: '群成员活跃统计',
|
||||
description: '查询指定群在时间窗口内的活跃成员、沉默成员和数据完整性。',
|
||||
parameters: [
|
||||
{ key: 'conversationId', label: '群会话 ID', required: true, placeholder: 'xxx@chatroom' },
|
||||
{ key: 'start', label: '开始时间', required: true, placeholder: '2026-10-01T00:00:00+07:00' },
|
||||
{ key: 'end', label: '结束时间', required: true, placeholder: '2026-10-02T23:59:59+07:00' }
|
||||
]
|
||||
}),
|
||||
endpoint('automations', {
|
||||
name: '自动化规则',
|
||||
description: '列出自动化规则,可按类型和启用状态筛选。',
|
||||
parameters: [
|
||||
{ key: 'type', label: '规则类型', placeholder: 'daily_report / scheduled_report / leave_notification' },
|
||||
{ key: 'enabled', label: '启用状态', placeholder: 'true 或 false' }
|
||||
]
|
||||
}),
|
||||
endpoint('automation-create', {
|
||||
name: '创建自动化规则',
|
||||
description: '创建一条默认停用的自动化规则。',
|
||||
body: true
|
||||
}),
|
||||
endpoint('automation-validate', {
|
||||
name: '校验自动化规则',
|
||||
description: '检查规则字段、目标标识和预计影响,不保存也不执行。',
|
||||
body: true
|
||||
}),
|
||||
endpoint('automation-executions', {
|
||||
name: '自动化执行记录',
|
||||
description: '分页上限内查询执行结果,可按规则、状态和时间筛选。',
|
||||
parameters: [
|
||||
{ key: 'ruleId', label: '规则 ID', placeholder: 'ruleId' },
|
||||
{ key: 'status', label: '状态', placeholder: 'running / success / failed' },
|
||||
{ key: 'since', label: '开始时间', placeholder: '2026-10-01T00:00:00+07:00' },
|
||||
{ key: 'until', label: '结束时间', placeholder: '2026-10-02T23:59:59+07:00' },
|
||||
{ key: 'limit', label: '数量上限', placeholder: '50,最大 200' }
|
||||
]
|
||||
}),
|
||||
endpoint('report', {
|
||||
name: '群聊日报导出',
|
||||
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ApiMethod = 'GET' | 'POST'
|
||||
export type ApiMethod = 'GET' | 'POST' | 'PATCH'
|
||||
export type { ApiTokenStatus } from '../../../../../shared/local-api-auth'
|
||||
|
||||
export interface ApiParameter {
|
||||
|
||||
@@ -4,8 +4,19 @@ export function buildApiUrl(
|
||||
path: string,
|
||||
params: Record<string, string>
|
||||
): string {
|
||||
const url = new URL(path, `http://${host}:${port}`)
|
||||
let resolvedPath = path
|
||||
const pathParameterKeys = new Set(
|
||||
Object.keys(params).filter((key) => path.includes(`{${key}}`))
|
||||
)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = value.trim()
|
||||
if (normalized && resolvedPath.includes(`{${key}}`)) {
|
||||
resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(normalized))
|
||||
}
|
||||
})
|
||||
const url = new URL(resolvedPath, `http://${host}:${port}`)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (pathParameterKeys.has(key)) return
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
})
|
||||
return url.toString()
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import {
|
||||
AUTOMATION_REPLY_DELAY_MAX_SECONDS,
|
||||
SCHEDULED_REPORT_MAX_TIMEOUT_SECONDS,
|
||||
SCHEDULED_REPORT_MIN_TIMEOUT_SECONDS,
|
||||
SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
|
||||
normalizeRuleDraft,
|
||||
type AutomationRuleDraft,
|
||||
type AutomationRuleType
|
||||
} from '../automation'
|
||||
import { validateGroupExitNotificationTemplate } from '../group-exit-monitor'
|
||||
import { isSelectableReportTemplateId } from '../report-templates'
|
||||
import {
|
||||
type ScheduledReportMemberNameMode,
|
||||
type ScheduledReportMessageType,
|
||||
type ScheduledReportRange
|
||||
} from '../scheduled-report'
|
||||
import type { AgentAutomationValidationIssue } from './contracts'
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'scheduled_report', 'leave_notification']
|
||||
const SCOPES = ['group', 'direct', 'all'] as const
|
||||
const KEYWORD_MODES = ['contains', 'exact', 'prefix'] as const
|
||||
const ACTION_TYPES = ['replyText', 'generateReport', 'sendReportImage'] as const
|
||||
const SCHEDULE_RANGES: ScheduledReportRange[] = ['today', 'yesterday', '7days', 'recent24h']
|
||||
const MESSAGE_TYPES: ScheduledReportMessageType[] = [
|
||||
'text',
|
||||
'image',
|
||||
'sticker',
|
||||
'video',
|
||||
'voice',
|
||||
'share',
|
||||
'system'
|
||||
]
|
||||
const MEMBER_NAME_MODES: ScheduledReportMemberNameMode[] = [
|
||||
'groupNickname',
|
||||
'wechatNickname',
|
||||
'remark'
|
||||
]
|
||||
const TARGET_TYPES = ['source_chat', 'self', 'file_transfer', 'contact'] as const
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function addIssue(
|
||||
issues: AgentAutomationValidationIssue[],
|
||||
path: string,
|
||||
code: string,
|
||||
message: string
|
||||
): void {
|
||||
issues.push({ path, code, message })
|
||||
}
|
||||
|
||||
function checkObject(
|
||||
value: unknown,
|
||||
path: string,
|
||||
allowedKeys: readonly string[],
|
||||
issues: AgentAutomationValidationIssue[]
|
||||
): JsonRecord | undefined {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
addIssue(issues, path, 'invalid_type', '必须是 JSON 对象')
|
||||
return undefined
|
||||
}
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
addIssue(issues, `${path}.${key}`, 'unknown_field', '不支持此字段')
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
function checkString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: AgentAutomationValidationIssue[],
|
||||
options: { required?: boolean; max?: number; allowEmpty?: boolean } = {}
|
||||
): value is string {
|
||||
if (typeof value !== 'string') {
|
||||
if (options.required || value !== undefined) {
|
||||
addIssue(issues, path, 'invalid_type', '必须是字符串')
|
||||
}
|
||||
return false
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (options.required && !trimmed) addIssue(issues, path, 'required', '不能为空')
|
||||
if (!options.allowEmpty && !options.required && value.length > 0 && !trimmed) {
|
||||
addIssue(issues, path, 'invalid_value', '不能只包含空白字符')
|
||||
}
|
||||
if (options.max !== undefined && value.length > options.max) {
|
||||
addIssue(issues, path, 'too_long', `最多 ${options.max} 个字符`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function checkBoolean(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: AgentAutomationValidationIssue[],
|
||||
required = false
|
||||
): void {
|
||||
if (value === undefined && !required) return
|
||||
if (typeof value !== 'boolean') addIssue(issues, path, 'invalid_type', '必须是布尔值')
|
||||
}
|
||||
|
||||
function checkInteger(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: AgentAutomationValidationIssue[],
|
||||
min: number,
|
||||
max: number,
|
||||
required = false
|
||||
): void {
|
||||
if (value === undefined && !required) return
|
||||
if (!Number.isInteger(value) || Number(value) < min || Number(value) > max) {
|
||||
addIssue(issues, path, 'out_of_range', `必须是 ${min} 到 ${max} 之间的整数`)
|
||||
}
|
||||
}
|
||||
|
||||
function checkEnum(
|
||||
value: unknown,
|
||||
path: string,
|
||||
values: readonly string[],
|
||||
issues: AgentAutomationValidationIssue[],
|
||||
required = false
|
||||
): void {
|
||||
if (value === undefined && !required) return
|
||||
if (typeof value !== 'string' || !values.includes(value)) {
|
||||
addIssue(issues, path, 'invalid_enum', `仅支持:${values.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateDailyReport(raw: JsonRecord, issues: AgentAutomationValidationIssue[]): void {
|
||||
const conditions = checkObject(
|
||||
raw.conditions,
|
||||
'conditions',
|
||||
['requireMentionMe', 'keyword', 'keywordMatchMode', 'conversationIds', 'ignoreSelf'],
|
||||
issues
|
||||
)
|
||||
if (conditions) {
|
||||
checkBoolean(conditions.requireMentionMe, 'conditions.requireMentionMe', issues, true)
|
||||
checkBoolean(conditions.ignoreSelf, 'conditions.ignoreSelf', issues, true)
|
||||
if (conditions.keyword === undefined) {
|
||||
addIssue(issues, 'conditions.keyword', 'required', '必须是字符串')
|
||||
} else {
|
||||
checkString(conditions.keyword, 'conditions.keyword', issues, { max: 200, allowEmpty: true })
|
||||
}
|
||||
checkEnum(
|
||||
conditions.keywordMatchMode,
|
||||
'conditions.keywordMatchMode',
|
||||
KEYWORD_MODES,
|
||||
issues,
|
||||
true
|
||||
)
|
||||
if (!Array.isArray(conditions.conversationIds)) {
|
||||
addIssue(issues, 'conditions.conversationIds', 'invalid_type', '必须是 ID 数组')
|
||||
} else {
|
||||
if (conditions.conversationIds.length > 100) {
|
||||
addIssue(issues, 'conditions.conversationIds', 'too_many_items', '最多支持 100 个会话')
|
||||
}
|
||||
conditions.conversationIds.forEach((id, index) =>
|
||||
checkString(id, `conditions.conversationIds[${index}]`, issues, {
|
||||
required: true,
|
||||
max: 300
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(raw.actions)) {
|
||||
addIssue(issues, 'actions', 'invalid_type', '必须是动作数组')
|
||||
return
|
||||
}
|
||||
if (raw.actions.length > 3) addIssue(issues, 'actions', 'too_many_items', '最多支持 3 个动作')
|
||||
const seen = new Set<string>()
|
||||
raw.actions.forEach((actionValue, index) => {
|
||||
const path = `actions[${index}]`
|
||||
const action = checkObject(actionValue, path, ['type', 'enabled', 'text'], issues)
|
||||
if (!action) return
|
||||
checkEnum(action.type, `${path}.type`, ACTION_TYPES, issues, true)
|
||||
checkBoolean(action.enabled, `${path}.enabled`, issues, true)
|
||||
if (typeof action.type === 'string') {
|
||||
if (seen.has(action.type))
|
||||
addIssue(issues, `${path}.type`, 'duplicate_action', '动作不能重复')
|
||||
seen.add(action.type)
|
||||
if (action.type === 'replyText') {
|
||||
checkString(action.text, `${path}.text`, issues, {
|
||||
required: action.enabled === true,
|
||||
max: 2_000,
|
||||
allowEmpty: action.enabled !== true
|
||||
})
|
||||
} else if (action.text !== undefined) {
|
||||
addIssue(issues, `${path}.text`, 'unknown_field', '此动作不支持 text')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function validateTarget(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: AgentAutomationValidationIssue[]
|
||||
): void {
|
||||
const target = checkObject(value, path, ['type', 'contactId'], issues)
|
||||
if (!target) return
|
||||
checkEnum(target.type, `${path}.type`, TARGET_TYPES, issues, true)
|
||||
if (target.type === 'contact') {
|
||||
checkString(target.contactId, `${path}.contactId`, issues, { required: true, max: 300 })
|
||||
} else if (target.contactId !== undefined) {
|
||||
addIssue(issues, `${path}.contactId`, 'unexpected_field', '仅 contact 目标可设置 contactId')
|
||||
}
|
||||
}
|
||||
|
||||
function validateScheduledReport(raw: JsonRecord, issues: AgentAutomationValidationIssue[]): void {
|
||||
checkBoolean(raw.targetNeedsReview, 'scheduledReport.targetNeedsReview', issues)
|
||||
if (raw.targetNeedsReview === true) {
|
||||
addIssue(
|
||||
issues,
|
||||
'scheduledReport.targetNeedsReview',
|
||||
'target_needs_review',
|
||||
'发送目标需要在 TraceMemo 中重新确认'
|
||||
)
|
||||
}
|
||||
if (raw.postfixText !== undefined) {
|
||||
checkString(raw.postfixText, 'scheduledReport.postfixText', issues, {
|
||||
max: SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
|
||||
allowEmpty: true
|
||||
})
|
||||
}
|
||||
|
||||
const schedule = checkObject(raw.schedule, 'scheduledReport.schedule', ['time'], issues)
|
||||
if (schedule) {
|
||||
if (typeof schedule.time !== 'string' || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(schedule.time)) {
|
||||
addIssue(issues, 'scheduledReport.schedule.time', 'invalid_time', '时间必须使用 HH:mm 格式')
|
||||
}
|
||||
}
|
||||
|
||||
const report = checkObject(
|
||||
raw.report,
|
||||
'scheduledReport.report',
|
||||
[
|
||||
'sourceConversationId',
|
||||
'range',
|
||||
'messageTypes',
|
||||
'templateId',
|
||||
'memberNameMode',
|
||||
'timeoutSeconds'
|
||||
],
|
||||
issues
|
||||
)
|
||||
if (report) {
|
||||
checkString(
|
||||
report.sourceConversationId,
|
||||
'scheduledReport.report.sourceConversationId',
|
||||
issues,
|
||||
{
|
||||
required: true,
|
||||
max: 300
|
||||
}
|
||||
)
|
||||
checkEnum(report.range, 'scheduledReport.report.range', SCHEDULE_RANGES, issues, true)
|
||||
if (!Array.isArray(report.messageTypes) || report.messageTypes.length === 0) {
|
||||
addIssue(issues, 'scheduledReport.report.messageTypes', 'required', '至少选择一种消息类型')
|
||||
} else {
|
||||
report.messageTypes.forEach((type, index) =>
|
||||
checkEnum(
|
||||
type,
|
||||
`scheduledReport.report.messageTypes[${index}]`,
|
||||
MESSAGE_TYPES,
|
||||
issues,
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
if (!isSelectableReportTemplateId(report.templateId)) {
|
||||
addIssue(issues, 'scheduledReport.report.templateId', 'invalid_template', '模板 ID 不受支持')
|
||||
}
|
||||
checkEnum(
|
||||
report.memberNameMode,
|
||||
'scheduledReport.report.memberNameMode',
|
||||
MEMBER_NAME_MODES,
|
||||
issues,
|
||||
true
|
||||
)
|
||||
checkInteger(
|
||||
report.timeoutSeconds,
|
||||
'scheduledReport.report.timeoutSeconds',
|
||||
issues,
|
||||
SCHEDULED_REPORT_MIN_TIMEOUT_SECONDS,
|
||||
SCHEDULED_REPORT_MAX_TIMEOUT_SECONDS,
|
||||
true
|
||||
)
|
||||
}
|
||||
validateTarget(raw.target, 'scheduledReport.target', issues)
|
||||
}
|
||||
|
||||
function validateLeaveNotification(
|
||||
raw: JsonRecord,
|
||||
issues: AgentAutomationValidationIssue[]
|
||||
): void {
|
||||
checkBoolean(raw.targetNeedsReview, 'leaveNotification.targetNeedsReview', issues)
|
||||
if (raw.targetNeedsReview === true) {
|
||||
addIssue(
|
||||
issues,
|
||||
'leaveNotification.targetNeedsReview',
|
||||
'target_needs_review',
|
||||
'发送目标需要在 TraceMemo 中重新确认'
|
||||
)
|
||||
}
|
||||
validateTarget(raw.target, 'leaveNotification.target', issues)
|
||||
checkEnum(raw.notifyScope, 'leaveNotification.notifyScope', ['all', 'selected'], issues, true)
|
||||
if (!Array.isArray(raw.notifyRoomIds)) {
|
||||
addIssue(issues, 'leaveNotification.notifyRoomIds', 'invalid_type', '必须是群聊 ID 数组')
|
||||
} else {
|
||||
if (raw.notifyRoomIds.length > 500) {
|
||||
addIssue(issues, 'leaveNotification.notifyRoomIds', 'too_many_items', '最多支持 500 个群聊')
|
||||
}
|
||||
raw.notifyRoomIds.forEach((id, index) =>
|
||||
checkString(id, `leaveNotification.notifyRoomIds[${index}]`, issues, {
|
||||
required: true,
|
||||
max: 300
|
||||
})
|
||||
)
|
||||
}
|
||||
checkString(raw.template, 'leaveNotification.template', issues, { required: true, max: 2_000 })
|
||||
const templateResult = validateGroupExitNotificationTemplate(raw.template)
|
||||
if (!templateResult.valid) {
|
||||
addIssue(
|
||||
issues,
|
||||
'leaveNotification.template',
|
||||
'invalid_template',
|
||||
templateResult.error || '模板无效'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Strict structural validation before the existing tolerant Store normalizer is called. */
|
||||
export function validateAutomationDraftShape(input: unknown): {
|
||||
valid: boolean
|
||||
issues: AgentAutomationValidationIssue[]
|
||||
normalized?: AutomationRuleDraft
|
||||
} {
|
||||
const issues: AgentAutomationValidationIssue[] = []
|
||||
const raw = checkObject(
|
||||
input,
|
||||
'draft',
|
||||
[
|
||||
'name',
|
||||
'enabled',
|
||||
'ruleType',
|
||||
'trigger',
|
||||
'scope',
|
||||
'conditions',
|
||||
'actions',
|
||||
'cooldownSeconds',
|
||||
'replyDelaySeconds',
|
||||
'leaveNotification',
|
||||
'scheduledReport'
|
||||
],
|
||||
issues
|
||||
)
|
||||
if (!raw) return { valid: false, issues }
|
||||
|
||||
checkString(raw.name, 'name', issues, { required: true, max: 100 })
|
||||
checkEnum(raw.ruleType, 'ruleType', RULE_TYPES, issues, true)
|
||||
if (raw.enabled !== undefined) checkBoolean(raw.enabled, 'enabled', issues, true)
|
||||
if (raw.enabled === true) {
|
||||
addIssue(
|
||||
issues,
|
||||
'enabled',
|
||||
'use_enable_operation',
|
||||
'创建和校验不能启用规则,请使用 enable 操作'
|
||||
)
|
||||
}
|
||||
checkEnum(raw.trigger, 'trigger', ['message'], issues, false)
|
||||
checkEnum(raw.scope, 'scope', SCOPES, issues, false)
|
||||
checkInteger(raw.cooldownSeconds, 'cooldownSeconds', issues, 0, 86_400)
|
||||
checkInteger(
|
||||
raw.replyDelaySeconds,
|
||||
'replyDelaySeconds',
|
||||
issues,
|
||||
0,
|
||||
AUTOMATION_REPLY_DELAY_MAX_SECONDS
|
||||
)
|
||||
|
||||
if (raw.ruleType !== 'scheduled_report' && raw.scheduledReport !== undefined) {
|
||||
addIssue(issues, 'scheduledReport', 'unexpected_field', '此规则类型不支持 scheduledReport')
|
||||
}
|
||||
if (raw.ruleType !== 'leave_notification' && raw.leaveNotification !== undefined) {
|
||||
addIssue(issues, 'leaveNotification', 'unexpected_field', '此规则类型不支持 leaveNotification')
|
||||
}
|
||||
|
||||
if (raw.ruleType === 'daily_report') validateDailyReport(raw, issues)
|
||||
if (raw.ruleType === 'scheduled_report') {
|
||||
if (raw.scheduledReport === undefined) {
|
||||
addIssue(issues, 'scheduledReport', 'required', '必须提供定时日报配置')
|
||||
} else {
|
||||
const scheduled = checkObject(
|
||||
raw.scheduledReport,
|
||||
'scheduledReport',
|
||||
['schedule', 'report', 'target', 'postfixText', 'targetNeedsReview'],
|
||||
issues
|
||||
)
|
||||
if (scheduled) validateScheduledReport(scheduled, issues)
|
||||
}
|
||||
}
|
||||
if (raw.ruleType === 'leave_notification') {
|
||||
if (raw.leaveNotification === undefined) {
|
||||
addIssue(issues, 'leaveNotification', 'required', '必须提供退群通知配置')
|
||||
} else {
|
||||
const leave = checkObject(
|
||||
raw.leaveNotification,
|
||||
'leaveNotification',
|
||||
['target', 'template', 'notifyScope', 'notifyRoomIds', 'targetNeedsReview'],
|
||||
issues
|
||||
)
|
||||
if (leave) validateLeaveNotification(leave, issues)
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length) return { valid: false, issues }
|
||||
const ruleType = raw.ruleType as AutomationRuleType
|
||||
const normalized = normalizeRuleDraft({
|
||||
...raw,
|
||||
enabled: false,
|
||||
...(ruleType === 'daily_report'
|
||||
? {}
|
||||
: {
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
requireMentionMe: false,
|
||||
keyword: '',
|
||||
keywordMatchMode: 'contains',
|
||||
conversationIds: [],
|
||||
ignoreSelf: true
|
||||
},
|
||||
actions: [],
|
||||
cooldownSeconds: 0
|
||||
})
|
||||
})
|
||||
return { valid: true, issues: [], normalized: { ...normalized, enabled: false } }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AutomationExecution, AutomationRuleType } from '../automation'
|
||||
|
||||
export interface AgentApiErrorBody {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface CapabilityAvailability {
|
||||
supported: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
operations?: string[]
|
||||
}
|
||||
|
||||
export interface ApplicationCapabilities {
|
||||
version: string
|
||||
apiVersion: 'v1'
|
||||
database: { ready: boolean }
|
||||
query: CapabilityAvailability
|
||||
automations: CapabilityAvailability & {
|
||||
ruleTypes: AutomationRuleType[]
|
||||
operations: string[]
|
||||
}
|
||||
groupExitMonitor: CapabilityAvailability
|
||||
groupStats: CapabilityAvailability
|
||||
wechat: {
|
||||
personal: CapabilityAvailability & {
|
||||
status: string
|
||||
content: { text: boolean; image: boolean; voice: boolean }
|
||||
}
|
||||
ilink: CapabilityAvailability & { status: string }
|
||||
}
|
||||
}
|
||||
|
||||
export interface AgentAutomationValidationIssue {
|
||||
path: string
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export interface AgentAutomationValidationResult {
|
||||
valid: boolean
|
||||
issues: AgentAutomationValidationIssue[]
|
||||
normalized?: unknown
|
||||
effects?: Record<string, unknown>
|
||||
nextRunAt?: string | null
|
||||
capabilities?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AgentAutomationExecution extends Pick<
|
||||
AutomationExecution,
|
||||
| 'executionId'
|
||||
| 'ruleId'
|
||||
| 'ruleName'
|
||||
| 'trigger'
|
||||
| 'status'
|
||||
| 'triggerTime'
|
||||
| 'durationMs'
|
||||
| 'sourceDisplayName'
|
||||
> {
|
||||
ruleType?: AutomationRuleType
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface AgentGroupExitMonitorState {
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
nativeMonitorActive: boolean
|
||||
monitoredConversationIds: string[]
|
||||
monitoredGroupCount: number
|
||||
monitorSelectionConfigured: boolean
|
||||
lastCheckedAt: string | null
|
||||
lastReadAt: string | null
|
||||
eventCount: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export interface AgentGroupExitMonitorEvent {
|
||||
eventId: string
|
||||
conversationId: string
|
||||
groupName: string
|
||||
memberId: string
|
||||
memberName: string
|
||||
wechatName: string
|
||||
groupRemark: string
|
||||
contactRemark: string
|
||||
previousCount: number
|
||||
currentCount: number
|
||||
delta: number
|
||||
message: string
|
||||
detectedAt: string
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface AgentGroupMemberStats {
|
||||
conversation: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
conversationId: string
|
||||
range: {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
memberCount: number
|
||||
activeMemberCount: number
|
||||
silentMemberCount: number
|
||||
activeMembers: Array<{
|
||||
memberId: string
|
||||
displayName: string
|
||||
groupNickname: string
|
||||
messageCount: number
|
||||
lastMessageAt: string | null
|
||||
}>
|
||||
silentMembers: Array<{
|
||||
memberId: string
|
||||
displayName: string
|
||||
groupNickname: string
|
||||
}>
|
||||
freshness: 'fresh' | 'stale' | 'unknown'
|
||||
complete: boolean
|
||||
limitations: string[]
|
||||
unattributedMessages: number
|
||||
excludedSystemMessages: number
|
||||
firstMessageAt: string | null
|
||||
}
|
||||
@@ -21,6 +21,39 @@ export const LOCAL_API_ENDPOINTS = {
|
||||
path: '/api/v1/scheduled-reports',
|
||||
queryKeys: []
|
||||
},
|
||||
'app-capabilities': { method: 'GET', path: '/api/v1/capabilities', queryKeys: [] },
|
||||
automations: {
|
||||
method: 'GET',
|
||||
path: '/api/v1/automations',
|
||||
queryKeys: ['type', 'enabled']
|
||||
},
|
||||
'automation-create': { method: 'POST', path: '/api/v1/automations', queryKeys: [] },
|
||||
'automation-validate': { method: 'POST', path: '/api/v1/automations/validate', queryKeys: [] },
|
||||
'automation-executions': {
|
||||
method: 'GET',
|
||||
path: '/api/v1/automations/executions',
|
||||
queryKeys: ['ruleId', 'status', 'since', 'until', 'limit']
|
||||
},
|
||||
'group-exit-monitor': {
|
||||
method: 'GET',
|
||||
path: '/api/v1/monitors/group-exits',
|
||||
queryKeys: []
|
||||
},
|
||||
'group-exit-monitor-update': {
|
||||
method: 'PATCH',
|
||||
path: '/api/v1/monitors/group-exits',
|
||||
queryKeys: []
|
||||
},
|
||||
'group-exit-events': {
|
||||
method: 'GET',
|
||||
path: '/api/v1/monitors/group-exits/events',
|
||||
queryKeys: ['conversationId', 'since', 'until', 'limit']
|
||||
},
|
||||
'group-member-stats': {
|
||||
method: 'GET',
|
||||
path: '/api/v1/groups/{conversationId}/member-stats',
|
||||
queryKeys: ['conversationId', 'start', 'end']
|
||||
},
|
||||
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] },
|
||||
'agent-status': { method: 'GET', path: '/api/v1/agent/status', queryKeys: [] },
|
||||
'agent-group-report': { method: 'POST', path: '/api/v1/agent/group-report', queryKeys: [] },
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
root: `/tmp/tracememo-agent-api-phase2-${process.pid}`,
|
||||
contacts: [
|
||||
{
|
||||
m_nsUsrName: 'room@chatroom',
|
||||
m_nsNickName: '产品群',
|
||||
md5: 'room-md5',
|
||||
type: 'group' as const
|
||||
},
|
||||
{
|
||||
m_nsUsrName: 'wxid_friend',
|
||||
m_nsNickName: 'Alice',
|
||||
md5: 'friend-md5',
|
||||
type: 'user' as const,
|
||||
wxid: 'wxid_friend'
|
||||
}
|
||||
],
|
||||
state: {
|
||||
enabled: true,
|
||||
running: true,
|
||||
nativeMonitorActive: true,
|
||||
monitoredRoomIds: ['room@chatroom'],
|
||||
monitoredGroupCount: 1,
|
||||
monitorSelectionConfigured: true,
|
||||
totalEventCount: 2,
|
||||
lastCheckedAt: 1_728_000_000_000,
|
||||
lastReadAt: 1_727_000_000_000,
|
||||
unreadCount: 1
|
||||
},
|
||||
events: [
|
||||
{
|
||||
id: 'event-2',
|
||||
contactId: 'room-md5',
|
||||
roomId: 'room@chatroom',
|
||||
groupName: '产品群',
|
||||
memberWxid: 'wxid_b',
|
||||
memberName: '乙',
|
||||
wechatName: '乙',
|
||||
groupRemark: '群乙',
|
||||
contactRemark: '',
|
||||
previousCount: 3,
|
||||
currentCount: 2,
|
||||
delta: -1,
|
||||
message: '乙退出了产品群',
|
||||
detectedAt: 1_728_000_000_000
|
||||
},
|
||||
{
|
||||
id: 'event-1',
|
||||
contactId: 'room-md5',
|
||||
roomId: 'room@chatroom',
|
||||
groupName: '产品群',
|
||||
memberWxid: 'wxid_a',
|
||||
memberName: '甲',
|
||||
wechatName: '甲',
|
||||
groupRemark: '',
|
||||
contactRemark: '',
|
||||
previousCount: 4,
|
||||
currentCount: 3,
|
||||
delta: -1,
|
||||
message: '甲退出了产品群',
|
||||
detectedAt: 1_727_000_000_000
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => fixture.root, getVersion: () => 'test-version' },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
isReady: () => true,
|
||||
listContacts: () => fixture.contacts,
|
||||
listContactsAsync: async () => fixture.contacts,
|
||||
listMessages: () => [],
|
||||
getGroupSnapshot: () => null,
|
||||
listRecentChat: () => [],
|
||||
resolveMd5: () => null
|
||||
}))
|
||||
vi.mock('../../src/main/group-report-service', () => ({ exportGroupReport: vi.fn() }))
|
||||
vi.mock('../../src/main/services/agent-group-report-service', () => ({
|
||||
generateAgentGroupReport: vi.fn()
|
||||
}))
|
||||
vi.mock('../../src/main/services/agent-hub-service', () => ({
|
||||
agentHubService: { getStatus: () => ({ connector: 'offline' }) }
|
||||
}))
|
||||
|
||||
import { LocalAgentApiService } from '../../src/main/services/local-agent-api-service'
|
||||
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
|
||||
import type { GroupMemberStatsResult } from '../../src/shared/group-stats'
|
||||
|
||||
const TOKEN = 'T'.repeat(43)
|
||||
const handles: HttpServerHandle[] = []
|
||||
|
||||
function createApi(): LocalAgentApiService {
|
||||
const monitorState = fixture.state
|
||||
return new LocalAgentApiService({
|
||||
automationRuleStore: {
|
||||
listRules: () => [],
|
||||
getRule: () => undefined,
|
||||
createRule: () => { throw new Error('not used') },
|
||||
updateRule: () => undefined,
|
||||
deleteRule: () => false,
|
||||
setRuleEnabled: () => undefined
|
||||
} as never,
|
||||
automationExecutionLogService: { list: () => [] } as never,
|
||||
listContacts: async () => fixture.contacts,
|
||||
isDatabaseReady: () => true,
|
||||
getVersion: () => 'test-version',
|
||||
getPersonalWechatCapability: async () => ({
|
||||
supported: true,
|
||||
ready: false,
|
||||
status: 'needs_binding',
|
||||
capabilities: { text: false, image: false, voice: false }
|
||||
} as never),
|
||||
getAgentHubStatus: () => ({ connector: 'offline' }),
|
||||
getGroupExitMonitorState: () => ({ ...monitorState }),
|
||||
configureGroupExitMonitor: async (configuration) => {
|
||||
if (configuration.monitoredRoomIds !== undefined) {
|
||||
monitorState.monitoredRoomIds = [...configuration.monitoredRoomIds]
|
||||
monitorState.monitoredGroupCount = monitorState.monitoredRoomIds.length
|
||||
}
|
||||
if (configuration.enabled !== undefined) monitorState.enabled = configuration.enabled
|
||||
monitorState.running = monitorState.enabled
|
||||
return { ...monitorState }
|
||||
},
|
||||
listGroupExitMonitorEvents: ({ roomId, sinceMs, untilMs, limit }) =>
|
||||
fixture.events
|
||||
.filter((event) => !roomId || event.roomId === roomId)
|
||||
.filter((event) => sinceMs === undefined || event.detectedAt >= sinceMs)
|
||||
.filter((event) => untilMs === undefined || event.detectedAt <= untilMs)
|
||||
.sort((left, right) => left.detectedAt - right.detectedAt)
|
||||
.slice(-limit!),
|
||||
getGroupMemberStats: async (query): Promise<GroupMemberStatsResult> => ({
|
||||
conversationId: query.userMd5,
|
||||
startTime: query.startTime,
|
||||
endTime: query.endTime,
|
||||
freshness: 'stale',
|
||||
complete: false,
|
||||
memberCount: 2,
|
||||
activeMemberCount: 1,
|
||||
silentMemberCount: 1,
|
||||
activeMembers: [{
|
||||
senderId: 'wxid_a',
|
||||
displayName: '甲',
|
||||
groupNickname: '群甲',
|
||||
messageCount: 3,
|
||||
lastMessageTime: 1_728_000_000_000
|
||||
}],
|
||||
silentMembers: [{ senderId: 'wxid_b', displayName: '乙', groupNickname: '' }],
|
||||
unattributedMessages: 4,
|
||||
excludedSystemMessages: 5,
|
||||
firstMessageTime: 1_727_000_000_000,
|
||||
limitations: ['本地索引尚未完全同步,以下结果可能不完整。']
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function startServer(): Promise<HttpServerHandle> {
|
||||
const handle = await startHttpServer('127.0.0.1', 0, {
|
||||
tokenProvider: () => TOKEN,
|
||||
agentApiService: createApi()
|
||||
})
|
||||
handles.push(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
async function request(
|
||||
handle: HttpServerHandle,
|
||||
pathname: string,
|
||||
method = 'GET',
|
||||
body?: unknown
|
||||
): Promise<Response> {
|
||||
return fetch(`http://${handle.host}:${handle.port}${pathname}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' })
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) })
|
||||
})
|
||||
}
|
||||
|
||||
describe('Agent API Phase 2', () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(handles.splice(0).map((handle) => handle.close()))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(handles.splice(0).map((handle) => handle.close()))
|
||||
})
|
||||
|
||||
it('advertises only the Phase 2 monitor and stats operations', async () => {
|
||||
const handle = await startServer()
|
||||
const response = await request(handle, '/api/v1/capabilities')
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.groupExitMonitor.operations).toEqual([
|
||||
'read_state',
|
||||
'configure_scope',
|
||||
'enable',
|
||||
'disable',
|
||||
'list_events'
|
||||
])
|
||||
expect(body.groupStats.operations).toEqual(['member_stats'])
|
||||
})
|
||||
|
||||
it('validates the complete monitor patch before changing scope', async () => {
|
||||
const handle = await startServer()
|
||||
const invalid = await request(handle, '/api/v1/monitors/group-exits', 'PATCH', {
|
||||
enabled: false,
|
||||
monitoredConversationIds: ['room@chatroom', 'missing@chatroom']
|
||||
})
|
||||
|
||||
expect(invalid.status).toBe(422)
|
||||
expect(fixture.state.enabled).toBe(true)
|
||||
expect(fixture.state.monitoredRoomIds).toEqual(['room@chatroom'])
|
||||
|
||||
const updated = await request(handle, '/api/v1/monitors/group-exits', 'PATCH', {
|
||||
enabled: false,
|
||||
monitoredConversationIds: []
|
||||
})
|
||||
expect(updated.status).toBe(200)
|
||||
expect(await updated.json()).toMatchObject({
|
||||
enabled: false,
|
||||
monitoredConversationIds: [],
|
||||
eventCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('returns stable event DTOs with time filters and ascending order', async () => {
|
||||
const handle = await startServer()
|
||||
const response = await request(
|
||||
handle,
|
||||
'/api/v1/monitors/group-exits/events?conversationId=room%40chatroom&since=2024-10-01T00%3A00%3A00%2B07%3A00&limit=1'
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toMatchObject({ count: 1 })
|
||||
expect(body.events[0]).toMatchObject({
|
||||
eventId: 'event-2',
|
||||
conversationId: 'room@chatroom',
|
||||
memberId: 'wxid_b',
|
||||
detectedAt: new Date(1_728_000_000_000).toISOString()
|
||||
})
|
||||
expect(body.events[0].read).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adapts stable group IDs and preserves stats freshness diagnostics', async () => {
|
||||
const handle = await startServer()
|
||||
const response = await request(
|
||||
handle,
|
||||
'/api/v1/groups/room%40chatroom/member-stats?start=2026-10-01T00%3A00%3A00%2B07%3A00&end=2026-10-02T00%3A00%3A00%2B07%3A00'
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toMatchObject({
|
||||
conversation: { id: 'room@chatroom', name: '产品群' },
|
||||
conversationId: 'room@chatroom',
|
||||
freshness: 'stale',
|
||||
complete: false,
|
||||
unattributedMessages: 4,
|
||||
excludedSystemMessages: 5,
|
||||
firstMessageAt: new Date(1_727_000_000_000).toISOString()
|
||||
})
|
||||
expect(body.activeMembers[0]).toMatchObject({
|
||||
memberId: 'wxid_a',
|
||||
lastMessageAt: new Date(1_728_000_000_000).toISOString()
|
||||
})
|
||||
|
||||
const direct = await request(
|
||||
handle,
|
||||
'/api/v1/groups/wxid_friend/member-stats?start=2026-10-01T00%3A00%3A00%2B07%3A00&end=2026-10-02T00%3A00%3A00%2B07%3A00'
|
||||
)
|
||||
expect(direct.status).toBe(422)
|
||||
expect((await direct.json()).error.code).toBe('NOT_GROUP_CONVERSATION')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,645 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'node:path'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const root = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = require('node:fs') as typeof import('node:fs')
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const os = require('node:os') as typeof import('node:os')
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const path = require('node:path') as typeof import('node:path')
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'tracememo-agent-api-'))
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => root, getVersion: () => 'test-version' },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value, 'utf8'),
|
||||
decryptString: (value: Buffer) => value.toString('utf8')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
isReady: () => true,
|
||||
listContacts: () => [],
|
||||
listContactsAsync: async () => [],
|
||||
listMessages: () => [],
|
||||
getGroupSnapshot: () => null,
|
||||
listRecentChat: () => [],
|
||||
resolveMd5: () => null
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/group-report-service', () => ({ exportGroupReport: vi.fn() }))
|
||||
vi.mock('../../src/main/services/agent-group-report-service', () => ({
|
||||
generateAgentGroupReport: vi.fn()
|
||||
}))
|
||||
vi.mock('../../src/main/services/agent-hub-service', () => ({
|
||||
agentHubService: {
|
||||
getStatus: () => ({ connector: 'offline' }),
|
||||
testSend: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/services/group-exit-monitor-service', () => ({
|
||||
groupExitMonitorService: {
|
||||
getState: () => ({ enabled: false, running: false, monitoredRoomIds: [] })
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
|
||||
} from '../../src/shared/automation'
|
||||
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
|
||||
import { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
|
||||
import { LocalAgentApiService } from '../../src/main/services/local-agent-api-service'
|
||||
import {
|
||||
apiServer,
|
||||
startHttpServer,
|
||||
type HttpServerHandle,
|
||||
type HttpServerOptions
|
||||
} from '../../src/main/http-server'
|
||||
import type { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
|
||||
|
||||
const TOKEN = 'T'.repeat(43)
|
||||
const handles: HttpServerHandle[] = []
|
||||
type TestDailyDraft = {
|
||||
name: string
|
||||
ruleType: string
|
||||
scope: string
|
||||
conditions: {
|
||||
requireMentionMe: boolean
|
||||
keyword: string
|
||||
keywordMatchMode: string
|
||||
conversationIds: string[]
|
||||
ignoreSelf: boolean
|
||||
}
|
||||
actions: Array<{ type: string; enabled: boolean; text?: string }>
|
||||
cooldownSeconds: number
|
||||
replyDelaySeconds: number
|
||||
}
|
||||
type TestScheduledDraft = {
|
||||
name: string
|
||||
ruleType: string
|
||||
scheduledReport: {
|
||||
schedule: { time: string }
|
||||
report: {
|
||||
sourceConversationId: string
|
||||
range: string
|
||||
messageTypes: string[]
|
||||
templateId: string
|
||||
memberNameMode: string
|
||||
timeoutSeconds: number
|
||||
}
|
||||
target: { type: string; contactId: string }
|
||||
postfixText: string
|
||||
}
|
||||
}
|
||||
type TestLeaveNotificationDraft = {
|
||||
name: string
|
||||
ruleType: string
|
||||
leaveNotification: {
|
||||
target: { type: string }
|
||||
template: string
|
||||
notifyScope: string
|
||||
notifyRoomIds: string[]
|
||||
targetNeedsReview?: boolean
|
||||
}
|
||||
}
|
||||
const contacts: Contact[] = [
|
||||
{
|
||||
m_nsUsrName: 'room@chatroom',
|
||||
m_nsNickName: '产品群',
|
||||
md5: 'group-md5',
|
||||
type: 'group'
|
||||
},
|
||||
{
|
||||
m_nsUsrName: 'wxid_friend',
|
||||
wxid: 'wxid_friend',
|
||||
m_nsNickName: 'Alice',
|
||||
md5: 'friend-md5',
|
||||
type: 'user'
|
||||
}
|
||||
]
|
||||
|
||||
let databaseReady = true
|
||||
let personalReady = false
|
||||
let currentStore: AutomationRuleStore
|
||||
let currentExecutionLog: AutomationExecutionLogService
|
||||
|
||||
function createApiService(
|
||||
store = currentStore,
|
||||
executionLog = currentExecutionLog
|
||||
): LocalAgentApiService {
|
||||
const personal = {
|
||||
supported: true,
|
||||
ready: personalReady,
|
||||
status: personalReady ? 'ready' : 'needs_binding',
|
||||
capabilities: { text: personalReady, image: false, voice: false },
|
||||
senderStatus: { executablePath: '/private/wechat/runtime' }
|
||||
} as unknown as PersonalWechatSendCapability
|
||||
return new LocalAgentApiService({
|
||||
automationRuleStore: store,
|
||||
automationExecutionLogService: executionLog,
|
||||
listContacts: async () => contacts,
|
||||
isDatabaseReady: () => databaseReady,
|
||||
getVersion: () => 'test-version',
|
||||
getPersonalWechatCapability: async () => personal,
|
||||
getAgentHubStatus: () => ({ connector: 'offline' }),
|
||||
getGroupExitMonitorState: () => ({ enabled: false, running: false, monitoredRoomIds: [] })
|
||||
})
|
||||
}
|
||||
|
||||
async function startServer(
|
||||
api = createApiService(),
|
||||
queryApiService?: HttpServerOptions['queryApiService']
|
||||
): Promise<HttpServerHandle> {
|
||||
const handle = await startHttpServer('127.0.0.1', 0, {
|
||||
tokenProvider: () => TOKEN,
|
||||
agentApiService: api,
|
||||
queryApiService
|
||||
})
|
||||
handles.push(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
function url(handle: HttpServerHandle, pathName: string): string {
|
||||
return `http://${handle.host}:${handle.port}${pathName}`
|
||||
}
|
||||
|
||||
async function request(
|
||||
handle: HttpServerHandle,
|
||||
pathName: string,
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<Response> {
|
||||
return fetch(url(handle, pathName), {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...headers
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
||||
})
|
||||
}
|
||||
|
||||
const validDailyDraft = (): TestDailyDraft => ({
|
||||
name: '产品群日报自动化',
|
||||
ruleType: 'daily_report',
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
requireMentionMe: true,
|
||||
keyword: '日报',
|
||||
keywordMatchMode: 'contains',
|
||||
conversationIds: ['room@chatroom'],
|
||||
ignoreSelf: true
|
||||
},
|
||||
actions: [{ type: 'replyText', enabled: true, text: '收到' }],
|
||||
cooldownSeconds: 60,
|
||||
replyDelaySeconds: 0
|
||||
})
|
||||
|
||||
const validScheduledDraft = (): TestScheduledDraft => ({
|
||||
name: '产品群每日日报',
|
||||
ruleType: 'scheduled_report',
|
||||
scheduledReport: {
|
||||
schedule: { time: '20:00' },
|
||||
report: {
|
||||
sourceConversationId: 'room@chatroom',
|
||||
range: 'today',
|
||||
messageTypes: ['text', 'image'],
|
||||
templateId: 'v1',
|
||||
memberNameMode: 'groupNickname',
|
||||
timeoutSeconds: 300
|
||||
},
|
||||
target: { type: 'contact', contactId: 'wxid_friend' },
|
||||
postfixText: '日报已生成'
|
||||
}
|
||||
})
|
||||
|
||||
const validLeaveNotificationDraft = (): TestLeaveNotificationDraft => ({
|
||||
name: '退群通知',
|
||||
ruleType: 'leave_notification',
|
||||
leaveNotification: {
|
||||
target: { type: 'file_transfer' },
|
||||
template: GROUP_EXIT_NOTIFICATION_TEMPLATE,
|
||||
notifyScope: 'all',
|
||||
notifyRoomIds: []
|
||||
}
|
||||
})
|
||||
|
||||
describe('Local Agent API', () => {
|
||||
beforeEach(() => {
|
||||
fs.removeSync(path.join(root, 'automation'))
|
||||
databaseReady = true
|
||||
personalReady = false
|
||||
currentStore = new AutomationRuleStore({ userDataPath: () => root })
|
||||
currentExecutionLog = new AutomationExecutionLogService({ userDataPath: () => root })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(handles.splice(0).map((handle) => handle.close()))
|
||||
await apiServer.stop()
|
||||
})
|
||||
|
||||
afterAll(() => fs.removeSync(root))
|
||||
|
||||
it('reports application version and separates supported from runtime availability', async () => {
|
||||
const handle = await startServer()
|
||||
let response = await request(handle, '/api/v1/capabilities')
|
||||
expect(response.status).toBe(200)
|
||||
let body = await response.json()
|
||||
expect(body).toMatchObject({
|
||||
version: 'test-version',
|
||||
apiVersion: 'v1',
|
||||
database: { ready: true },
|
||||
query: { supported: true, available: true },
|
||||
automations: {
|
||||
supported: true,
|
||||
available: true,
|
||||
ruleTypes: ['daily_report', 'scheduled_report', 'leave_notification']
|
||||
},
|
||||
wechat: {
|
||||
personal: { supported: true, available: false, status: 'needs_binding' },
|
||||
ilink: { supported: true, available: false, status: 'offline' }
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(body)).not.toMatch(/private\/wechat|executablePath|token|context_token/i)
|
||||
|
||||
databaseReady = false
|
||||
response = await request(handle, '/api/v1/capabilities')
|
||||
body = await response.json()
|
||||
expect(body).toMatchObject({
|
||||
database: { ready: false },
|
||||
query: { supported: true, available: false, reason: 'database_not_ready' },
|
||||
groupStats: { supported: true, available: false, reason: 'database_not_ready' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the Agent error envelope for authorization, method, missing route, and size failures', async () => {
|
||||
const handle = await startServer()
|
||||
const unauthorized = await fetch(url(handle, '/api/v1/capabilities'))
|
||||
expect(unauthorized.status).toBe(401)
|
||||
expect(await unauthorized.json()).toMatchObject({
|
||||
error: { code: 'UNAUTHORIZED' },
|
||||
requestId: expect.any(String)
|
||||
})
|
||||
|
||||
const wrongMethod = await request(handle, '/api/v1/capabilities', 'POST')
|
||||
expect(wrongMethod.status).toBe(405)
|
||||
expect(wrongMethod.headers.get('allow')).toBe('GET')
|
||||
expect(await wrongMethod.json()).toMatchObject({
|
||||
error: { code: 'METHOD_NOT_ALLOWED' },
|
||||
requestId: expect.any(String)
|
||||
})
|
||||
|
||||
const missingRoute = await request(handle, '/api/v1/automations/rule-1/run')
|
||||
expect(missingRoute.status).toBe(404)
|
||||
expect(await missingRoute.json()).toMatchObject({
|
||||
error: { code: 'NOT_FOUND' },
|
||||
requestId: expect.any(String)
|
||||
})
|
||||
|
||||
const oversized = await fetch(url(handle, '/api/v1/automations'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify('x'.repeat(1024 * 1024 + 1))
|
||||
})
|
||||
expect(oversized.status).toBe(413)
|
||||
expect(oversized.headers.get('x-request-id')).toEqual(expect.any(String))
|
||||
expect(await oversized.json()).toMatchObject({
|
||||
error: { code: 'PAYLOAD_TOO_LARGE' },
|
||||
requestId: expect.any(String)
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the existing Query operation request and response contract', async () => {
|
||||
const queryApi = {
|
||||
messages: vi.fn(async (payload: unknown) => ({ status: 'completed', payload }))
|
||||
} as unknown as LocalQueryApiService
|
||||
const handle = await startServer(createApiService(), queryApi)
|
||||
const payload = { target: { query: 'Alice' }, timeRange: { kind: 'all' } }
|
||||
const response = await request(handle, '/api/v1/query/messages', 'POST', payload)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ status: 'completed', payload })
|
||||
expect(queryApi.messages).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it('validates rule fields and resolves scheduled IDs without persisting', async () => {
|
||||
const handle = await startServer()
|
||||
const scheduled = validScheduledDraft()
|
||||
const response = await request(handle, '/api/v1/automations/validate', 'POST', scheduled)
|
||||
expect(response.status).toBe(200)
|
||||
const result = await response.json()
|
||||
expect(result).toMatchObject({ valid: true, normalized: { enabled: false } })
|
||||
expect(result.normalized.scheduledReport.report.sourceConversationId).toBe('room@chatroom')
|
||||
expect(result.normalized.scheduledReport.target.contactId).toBe('wxid_friend')
|
||||
expect(result.effects).toMatchObject({ sendsWechatMessage: true })
|
||||
expect(result.nextRunAt).toEqual(expect.any(String))
|
||||
expect(currentStore.listRules().some((rule) => rule.name === '产品群每日日报')).toBe(false)
|
||||
|
||||
const mentionOnly = validDailyDraft()
|
||||
mentionOnly.conditions.keyword = ''
|
||||
const mentionOnlyResponse = await request(
|
||||
handle,
|
||||
'/api/v1/automations/validate',
|
||||
'POST',
|
||||
mentionOnly
|
||||
)
|
||||
expect((await mentionOnlyResponse.json()).valid).toBe(true)
|
||||
|
||||
const invalidEnum = validDailyDraft()
|
||||
invalidEnum.conditions.keywordMatchMode = 'regex'
|
||||
const enumResponse = await request(handle, '/api/v1/automations/validate', 'POST', invalidEnum)
|
||||
const enumResult = await enumResponse.json()
|
||||
expect(enumResult.valid).toBe(false)
|
||||
expect(enumResult.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'conditions.keywordMatchMode', code: 'invalid_enum' })
|
||||
])
|
||||
)
|
||||
|
||||
const invalidTime = validScheduledDraft()
|
||||
invalidTime.scheduledReport.schedule.time = '29:90'
|
||||
const timeResponse = await request(handle, '/api/v1/automations/validate', 'POST', invalidTime)
|
||||
expect((await timeResponse.json()).issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'scheduledReport.schedule.time', code: 'invalid_time' })
|
||||
])
|
||||
)
|
||||
|
||||
const unknownFieldResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
|
||||
...validDailyDraft(),
|
||||
callbackUrl: 'http://127.0.0.1'
|
||||
})
|
||||
expect((await unknownFieldResponse.json()).issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'draft.callbackUrl', code: 'unknown_field' })
|
||||
])
|
||||
)
|
||||
|
||||
const invalidRuleTypeResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
|
||||
...validDailyDraft(),
|
||||
ruleType: 'webhook'
|
||||
})
|
||||
expect((await invalidRuleTypeResponse.json()).issues).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ path: 'ruleType', code: 'invalid_enum' })])
|
||||
)
|
||||
|
||||
const enabledInputResponse = await request(handle, '/api/v1/automations/validate', 'POST', {
|
||||
...validDailyDraft(),
|
||||
enabled: true
|
||||
})
|
||||
expect((await enabledInputResponse.json()).issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'enabled', code: 'use_enable_operation' })
|
||||
])
|
||||
)
|
||||
|
||||
const invalidAllScopeRoomResponse = await request(
|
||||
handle,
|
||||
'/api/v1/automations/validate',
|
||||
'POST',
|
||||
{
|
||||
...validLeaveNotificationDraft(),
|
||||
leaveNotification: {
|
||||
...validLeaveNotificationDraft().leaveNotification,
|
||||
notifyRoomIds: ['missing-room@chatroom']
|
||||
}
|
||||
}
|
||||
)
|
||||
expect((await invalidAllScopeRoomResponse.json()).issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'leaveNotification.notifyRoomIds[0]',
|
||||
code: 'contact_not_found'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('creates disabled rules, updates them, validates on enable, and protects system rules', async () => {
|
||||
const handle = await startServer()
|
||||
const createResponse = await request(handle, '/api/v1/automations', 'POST', validDailyDraft(), {
|
||||
'X-Request-Id': 'agent:create-rule'
|
||||
})
|
||||
expect(createResponse.status).toBe(201)
|
||||
expect(createResponse.headers.get('x-request-id')).toBe('agent:create-rule')
|
||||
const created = (await createResponse.json()).rule
|
||||
expect(created).toMatchObject({ enabled: false, ruleType: 'daily_report' })
|
||||
expect(created.conditions.conversationIds).toEqual(['room@chatroom'])
|
||||
|
||||
const getResponse = await request(handle, `/api/v1/automations/${created.id}`)
|
||||
expect(getResponse.status).toBe(200)
|
||||
expect((await getResponse.json()).rule.id).toBe(created.id)
|
||||
|
||||
const patchResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
|
||||
name: '更新后的名称'
|
||||
})
|
||||
expect((await patchResponse.json()).rule).toMatchObject({
|
||||
id: created.id,
|
||||
name: '更新后的名称',
|
||||
enabled: false
|
||||
})
|
||||
|
||||
const immutableResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
|
||||
ruleType: 'scheduled_report'
|
||||
})
|
||||
expect(immutableResponse.status).toBe(400)
|
||||
expect(await immutableResponse.json()).toMatchObject({
|
||||
error: { code: 'RULE_TYPE_IMMUTABLE' },
|
||||
requestId: expect.any(String)
|
||||
})
|
||||
|
||||
const metadataResponse = await request(handle, `/api/v1/automations/${created.id}`, 'PATCH', {
|
||||
createdAt: 0
|
||||
})
|
||||
expect(metadataResponse.status).toBe(400)
|
||||
expect((await metadataResponse.json()).error.code).toBe('IMMUTABLE_FIELD')
|
||||
|
||||
databaseReady = false
|
||||
const unavailableEnableResponse = await request(
|
||||
handle,
|
||||
`/api/v1/automations/${created.id}/enable`,
|
||||
'POST'
|
||||
)
|
||||
expect(unavailableEnableResponse.status).toBe(409)
|
||||
expect((await unavailableEnableResponse.json()).error.code).toBe('VALIDATION_FAILED')
|
||||
expect(currentStore.getRule(created.id)?.enabled).toBe(false)
|
||||
|
||||
databaseReady = true
|
||||
const enableResponse = await request(handle, `/api/v1/automations/${created.id}/enable`, 'POST')
|
||||
expect((await enableResponse.json()).rule.enabled).toBe(true)
|
||||
const disableResponse = await request(
|
||||
handle,
|
||||
`/api/v1/automations/${created.id}/disable`,
|
||||
'POST'
|
||||
)
|
||||
expect((await disableResponse.json()).rule.enabled).toBe(false)
|
||||
|
||||
const filteredResponse = await request(
|
||||
handle,
|
||||
'/api/v1/automations?type=daily_report&enabled=false'
|
||||
)
|
||||
const filtered = await filteredResponse.json()
|
||||
expect(filtered.rules.some((rule: { id: string }) => rule.id === created.id)).toBe(true)
|
||||
|
||||
const protectedDaily = await request(
|
||||
handle,
|
||||
`/api/v1/automations/${BUILTIN_DAILY_REPORT_RULE_ID}`,
|
||||
'DELETE'
|
||||
)
|
||||
expect(protectedDaily.status).toBe(409)
|
||||
expect((await protectedDaily.json()).error.code).toBe('PROTECTED_RULE')
|
||||
const protectedLeave = await request(
|
||||
handle,
|
||||
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
|
||||
'DELETE'
|
||||
)
|
||||
expect(protectedLeave.status).toBe(409)
|
||||
|
||||
const nonBuiltinLeave = currentStore.createRule(validLeaveNotificationDraft())
|
||||
const protectedNonBuiltinLeave = await request(
|
||||
handle,
|
||||
`/api/v1/automations/${nonBuiltinLeave.id}`,
|
||||
'DELETE'
|
||||
)
|
||||
expect(protectedNonBuiltinLeave.status).toBe(409)
|
||||
expect((await protectedNonBuiltinLeave.json()).error.code).toBe('PROTECTED_RULE')
|
||||
|
||||
const duplicateLeave = await request(handle, '/api/v1/automations', 'POST', {
|
||||
name: '第二条退群通知',
|
||||
ruleType: 'leave_notification',
|
||||
leaveNotification: {
|
||||
target: { type: 'file_transfer' },
|
||||
template: '{groupName} {user}',
|
||||
notifyScope: 'all',
|
||||
notifyRoomIds: []
|
||||
}
|
||||
})
|
||||
expect(duplicateLeave.status).toBe(409)
|
||||
expect((await duplicateLeave.json()).error.code).toBe('SINGLETON_RULE')
|
||||
|
||||
const deleteCustom = await request(handle, `/api/v1/automations/${created.id}`, 'DELETE')
|
||||
expect(deleteCustom.status).toBe(200)
|
||||
expect((await deleteCustom.json()).deletedId).toBe(created.id)
|
||||
})
|
||||
|
||||
it('lists bounded execution records with structured filters', async () => {
|
||||
const handle = await startServer()
|
||||
currentExecutionLog.record({
|
||||
executionId: 'execution-1',
|
||||
ruleId: BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
ruleName: '内置日报',
|
||||
triggerTime: Date.parse('2025-01-02T03:04:05.000Z'),
|
||||
trigger: 'message',
|
||||
sourceDisplayName: '产品群',
|
||||
status: 'failed',
|
||||
durationMs: 1_250,
|
||||
steps: [],
|
||||
errorSummary: '日报生成失败'
|
||||
})
|
||||
const response = await request(
|
||||
handle,
|
||||
'/api/v1/automations/executions?ruleId=builtin-mention-me-daily-report&status=failed&since=2025-01-01T00%3A00%3A00Z&until=2025-01-03T00%3A00%3A00Z&limit=10'
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
count: 1,
|
||||
executions: [
|
||||
{
|
||||
executionId: 'execution-1',
|
||||
ruleId: BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
ruleType: 'daily_report',
|
||||
trigger: 'message',
|
||||
status: 'failed',
|
||||
startedAt: '2025-01-02T03:04:05.000Z',
|
||||
finishedAt: '2025-01-02T03:04:06.250Z',
|
||||
error: '日报生成失败'
|
||||
}
|
||||
]
|
||||
})
|
||||
const invalidLimit = await request(handle, '/api/v1/automations/executions?limit=201')
|
||||
expect(invalidLimit.status).toBe(400)
|
||||
expect((await invalidLimit.json()).error.code).toBe('INVALID_ARGUMENT')
|
||||
})
|
||||
|
||||
it('only clears target review when a valid replacement target is supplied', async () => {
|
||||
const handle = await startServer()
|
||||
currentStore.saveLeaveNotificationRule({
|
||||
...validLeaveNotificationDraft(),
|
||||
enabled: false,
|
||||
leaveNotification: {
|
||||
...validLeaveNotificationDraft().leaveNotification,
|
||||
targetNeedsReview: true
|
||||
}
|
||||
})
|
||||
|
||||
const bypass = await request(
|
||||
handle,
|
||||
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
|
||||
'PATCH',
|
||||
{ leaveNotification: { targetNeedsReview: false } }
|
||||
)
|
||||
expect(bypass.status).toBe(400)
|
||||
expect((await bypass.json()).error.code).toBe('IMMUTABLE_FIELD')
|
||||
expect(
|
||||
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
|
||||
).toBe(true)
|
||||
|
||||
const invalidTarget = await request(
|
||||
handle,
|
||||
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
|
||||
'PATCH',
|
||||
{ leaveNotification: { target: {} } }
|
||||
)
|
||||
expect(invalidTarget.status).toBe(422)
|
||||
expect(
|
||||
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
|
||||
).toBe(true)
|
||||
|
||||
const updated = await request(
|
||||
handle,
|
||||
`/api/v1/automations/${BUILTIN_LEAVE_NOTIFICATION_RULE_ID}`,
|
||||
'PATCH',
|
||||
{ leaveNotification: { target: { type: 'self' } } }
|
||||
)
|
||||
expect(updated.status).toBe(200)
|
||||
expect((await updated.json()).rule.leaveNotification).not.toHaveProperty('targetNeedsReview')
|
||||
expect(
|
||||
currentStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.target
|
||||
).toEqual({ type: 'self' })
|
||||
})
|
||||
|
||||
it('returns a standard persistence error and rolls back the in-memory rule on write failure', async () => {
|
||||
const blockedPath = path.join(root, 'not-a-directory')
|
||||
fs.writeFileSync(blockedPath, 'file')
|
||||
const failingStore = new AutomationRuleStore({ userDataPath: () => blockedPath })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const handle = await startServer(createApiService(failingStore, currentExecutionLog))
|
||||
try {
|
||||
const response = await request(handle, '/api/v1/automations', 'POST', {
|
||||
...validDailyDraft(),
|
||||
name: '不得假成功'
|
||||
})
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.json()).toMatchObject({
|
||||
error: { code: 'PERSISTENCE_FAILED' },
|
||||
requestId: expect.any(String)
|
||||
})
|
||||
expect(failingStore.listRules().some((rule) => rule.name === '不得假成功')).toBe(false)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@ const fixture = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => fixture.root },
|
||||
app: { getPath: () => fixture.root, getVersion: () => 'test-version' },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => fixture.storageAvailable,
|
||||
encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'),
|
||||
@@ -111,7 +111,7 @@ describe('Local API authentication', () => {
|
||||
const health = await fetch(`${baseUrl(handle)}/api/v1/health`)
|
||||
expect(health.status).toBe(200)
|
||||
const healthBody = await health.json()
|
||||
expect(healthBody).toMatchObject({ ok: true, service: 'TraceMemo Reader' })
|
||||
expect(healthBody).toMatchObject({ ok: true, service: 'TraceMemo Reader', version: 'test-version' })
|
||||
expect(JSON.stringify(healthBody)).not.toMatch(
|
||||
/token|authorization|wxid|databasePath|provider/i
|
||||
)
|
||||
@@ -128,6 +128,64 @@ describe('Local API authentication', () => {
|
||||
await expect(response.json()).resolves.toMatchObject({ count: 1 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['POST', '/api/v1/health', 'GET'],
|
||||
['PATCH', '/api/v1/current_time', 'GET'],
|
||||
['POST', '/api/v1/contact', 'GET'],
|
||||
['POST', '/api/v1/chatroom', 'GET'],
|
||||
['POST', '/api/v1/recent_chat', 'GET'],
|
||||
['POST', '/api/v1/chatlog', 'GET'],
|
||||
['POST', '/api/v1/group_snapshot', 'GET'],
|
||||
['POST', '/api/v1/resolve', 'GET'],
|
||||
['POST', '/api/v1/agent/status', 'GET'],
|
||||
['GET', '/api/v1/report', 'POST'],
|
||||
['PATCH', '/api/v1/query/capabilities', 'GET'],
|
||||
['GET', '/api/v1/query/messages', 'POST'],
|
||||
['POST', '/api/v1/media/image-1', 'GET, HEAD']
|
||||
])('rejects unsupported methods with Allow: %s', async (method, pathname, allow) => {
|
||||
const handle = await startFixtureServer()
|
||||
const requestId = 'phase1:method-guard'
|
||||
const response = await fetch(`${baseUrl(handle)}${pathname}`, {
|
||||
method,
|
||||
headers: { 'X-Request-Id': requestId }
|
||||
})
|
||||
|
||||
expect(response.status).toBe(405)
|
||||
expect(response.headers.get('allow')).toBe(allow)
|
||||
expect(response.headers.get('x-request-id')).toBe(requestId)
|
||||
await expect(response.json()).resolves.toMatchObject({ requestId })
|
||||
})
|
||||
|
||||
it('rejects oversized JSON request bodies with 413 and a request ID', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const requestId = 'phase1:body-limit'
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${VALID_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': requestId
|
||||
},
|
||||
body: 'x'.repeat(1024 * 1024 + 1)
|
||||
})
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(response.headers.get('x-request-id')).toBe(requestId)
|
||||
await expect(response.json()).resolves.toMatchObject({ status: 413, requestId })
|
||||
})
|
||||
|
||||
it('replaces malformed or oversized client request IDs with a server UUID', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const malformedId = 'x'.repeat(129)
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/health`, {
|
||||
headers: { 'X-Request-Id': malformedId }
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('x-request-id')).toMatch(/^[0-9a-f-]{36}$/i)
|
||||
expect(response.headers.get('x-request-id')).not.toBe(malformedId)
|
||||
})
|
||||
|
||||
it('exposes the query capability catalog only with the existing bearer token', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const path = `${baseUrl(handle)}/api/v1/query/capabilities`
|
||||
@@ -181,6 +239,23 @@ describe('Local API authentication', () => {
|
||||
expect(JSON.stringify(response.headers)).not.toMatch(/path|token|database/i)
|
||||
})
|
||||
|
||||
it('preserves authenticated HEAD requests for media without returning a body', async () => {
|
||||
const provider = vi.fn(async () => ({
|
||||
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
|
||||
mimeType: 'image/jpeg'
|
||||
}))
|
||||
const handle = await startFixtureServer(() => VALID_TOKEN, provider)
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/media/message-1`, {
|
||||
method: 'HEAD',
|
||||
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-length')).toBe('4')
|
||||
expect((await response.arrayBuffer()).byteLength).toBe(0)
|
||||
expect(provider).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('adds media metadata to chatlog while redacting image keys', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/chatlog?talker=测试联系人`, {
|
||||
@@ -265,9 +340,10 @@ describe('Local API authentication', () => {
|
||||
headers: { Authorization: authorization }
|
||||
})
|
||||
expect(response.status).toBe(401)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: 'unauthorized',
|
||||
message: 'Valid API token required'
|
||||
message: 'Valid API token required',
|
||||
requestId: expect.any(String)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -344,7 +420,9 @@ describe('Local API authentication', () => {
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe(origin)
|
||||
expect(response.headers.get('access-control-allow-methods')).toContain('PATCH')
|
||||
expect(response.headers.get('access-control-allow-methods')).toContain('DELETE')
|
||||
expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization')
|
||||
expect(response.headers.get('access-control-allow-headers')).toBe(
|
||||
'Content-Type, Authorization, X-Request-Id'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('electron', () => ({
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
isReady: () => true,
|
||||
listContacts: () => fixture.contacts,
|
||||
listContactsAsync: async () => fixture.contacts,
|
||||
listMessages: () => [],
|
||||
getGroupSnapshot: () => null,
|
||||
listRecentChat: () => [],
|
||||
|
||||
Reference in New Issue
Block a user