Compare commits

..
Author SHA1 Message Date
wuyouMaster 7ebd4b8376 fix: make packaging checks platform safe 2026-09-20 18:05:12 +08:00
wuyouMaster 5a54202383 fix: ad-hoc sign packaged macOS key helpers and app bundle
electron-builder 26 skips macOS signing entirely when no Developer ID identity is configured, so the packaged key helpers can ship unsigned or with a broken signature and macOS kills them even with SIP disabled. afterPack now verifies and ad-hoc re-signs the packaged xkey helpers and the outer app bundle, failing the build when a signature cannot be repaired.
2026-09-20 17:52:38 +08:00
223 changed files with 9323 additions and 33200 deletions
+1 -2
View File
@@ -10,8 +10,7 @@ out
coverage/
playwright-report/
test-results/
resources/runtime/darwin-arm64
.native-runtime-source
resources/connectors/wechat-personal/
.omc
.codex/
skills-lock.json
+20 -30
View File
@@ -25,14 +25,16 @@
</p>
<p align="center">
<img src="./public/日报.png" alt="TraceMemo 日报" />
<img src="./public/日报.png" alt="TraceMemo 主界面" />
</p>
<p align="center">
<img src="./public/自动化.png" alt="TraceMemo 自动化" />
<img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
</p>
<p align="center">
<img src="./public/退群监控.png" alt="TraceMemo 退群监控" />
</p>
---
## 🎨 社区日报模板
@@ -47,7 +49,7 @@ TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多
在 TraceMemo 中打开:
**日报 → 社区模板市场**
**日报 → 今日日报 → 日报模板 → 模板市场**
即可查看、预览、安装和切换已发布的社区模板。
@@ -65,21 +67,18 @@ TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数
## 核心能力
- 💬 **聊天档案与搜索**:浏览会话,按关键词、备注、昵称或 wxid 查找消息。
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
- 🧠 **本地知识库**:在本机建立索引,让跨会话、跨时间的查询更稳定。
- 🖼️ **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图),识别结果可以在搜索和「问问微信」里被检索。识别全程不联网,原始图片不会因为本地识别而上传。
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结,可保存为 HTML 与 PNG。
- 🗣️ **群发言统计**:统计群成员的发言量和沉默成员,看清一个群里谁在说、谁一直没说。
- 👀 **退群监控**:用成员快照对比记录群成员退出事件,支持多群与事件历史。
- ⚙️ **自动化**:把上面几步按规则串起来——定时生成并发送日报、成员退群时发送通知;能发到哪里取决于当前的发送能力。
- 🔊 **文字转语音**:把文字生成语音,试听后发送到当前会话。
- 🤖 **Agent Hub**:在微信里向本机 TraceMemo 提问。
- 🔌 **外部 Agent / Local HTTP API**:让 Codex 等外部 Agent 查询本机微信历史。
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
## 💻 平台支持
TraceMemo 2.5.0 支持:
TraceMemo 2.4.0 支持:
- **Windows x64**
- **macOS Apple Silicon(M 系列 / arm64)**
@@ -87,12 +86,6 @@ TraceMemo 2.5.0 支持:
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
### 关于“发送能力”
浏览、搜索、日报生成、导出、知识库和图片文字索引都不需要额外的发送组件。只有**把内容真正发回微信**这一步——自动发送日报、退群通知、把语音发到会话——依赖本机发送能力:
发送能力未就绪、未绑定或发送失败时,报告本身仍会正常生成并保存在本机,执行记录会显示为“已生成,但未发送”或“已生成,发送失败”,可以稍后重试。
## 项目缘起
<details>
@@ -145,14 +138,11 @@ TraceMemo 最早叫 **WechatExplorer**。
| 想做什么 | 使用入口 |
| ---------------------------------- | ----------------------------- |
| 找记得原文或关键词的消息 | 档案搜索 |
| 找记得大意、但不知道在哪聊过的内容 | 问问微信(AI Search) |
| 找到截图、公告图里写过的文字 | 问问微信 → 图片文字索引 |
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
| 长期跨群查询历史 | 本地知识库 |
| 了解一个群今天或近 7 天聊了什么 | 日报 |
| 看群里谁最活跃、谁一直没说话 | 档案 → 群聊 → 群发言统计 |
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
| 持续关注群成员退出 | 退群监控 |
| 按计划自动生成并发送群聊日报 | 自动化 |
| 成员退群时自动发一条通知 | 自动化 → 退群通知 |
| 按计划生成并发送群聊日报 | 定时日报 |
| 把文字生成微信语音 | 文字转语音 |
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
@@ -169,9 +159,9 @@ TraceMemo 最早叫 **WechatExplorer**。
## 文档
- [用户指南](./docs/README.md#档案与搜索)
- [用户指南](./docs/README.md#用户指南)
- [AI / Knowledge](./docs/README.md#ai-与知识库)
- [日报与自动化](./docs/README.md#日报与自动化)
- [Monitor / Automation](./docs/README.md#日报与自动化)
- [Agent / API](./docs/README.md#agent--api)
- [开发文档](./docs/development/overview.md)
- [隐私与安全](./docs/user-guide/privacy.md)
@@ -223,7 +213,7 @@ TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.co
这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
+8 -7
View File
@@ -6,29 +6,29 @@
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口,以及群聊里的「群发言统计」。
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
## AI 与知识库
- [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性;也包括在本机识别图片文字、让截图和公告图变得可搜索的「图片文字索引」。
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
## 日报与自动化
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以在「自动化」里创建定时日报。
- 「自动化」按三类规则执行:**@我生成日报**、**定时日报**、**退群通知**。定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
- 定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./concepts/how-it-works.md#动作执行与审计)。
## 退群监控
## Monitor
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。成员退出同时是「自动化 → 退群通知」的触发条件。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
## 语音能力
- [语音转文字](./user-guide/voice.md):在本机转写微信语音,结果可用于搜索、Knowledge 和导出。
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊;实际发送依赖本机发送能力。
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊。
## Agent / API
@@ -61,6 +61,7 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md)
## 版本说明
+11 -12
View File
@@ -21,15 +21,14 @@ Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导
- “帮我看看最近跟某人聊了些什么。”
- “生成产品交流群今天的群聊总结图片。”
Hub 把入站文字分成两类处理。
当前已实现的实时任务包括:
**确定性的快捷动作**(不经过模型,命中就执行):
- 查看最近会话:数量限制为 1–20;
- 生成群聊总结图片:今天、昨天或近 7 天(需要同时提到“群”和“图片 / 长图 / 日报 / 报告”);
- 分析某个群成员的近期发言:可以指定“今天 / 昨天 / 最近 N 天”。
**其余问题**交给本机的 Query Agent:它和桌面端“问问微信”使用的是同一个实现,可以按需读取联系人、会话和时间范围来回答,必要时调用你在“设置 → AI 模型”里配置的 AI。例如“帮我看看最近跟某人聊了些什么”“上个月讨论过的项目地址在哪里”。
- 查看最近会话(数量限制为 1–20);
- 查询你和某位联系人的近期聊天;
- 用已配置的 AI 总结你和某位联系人近 7 天的聊天;
- 生成今天、昨天或近 7 天的群聊总结图片;
- 总结指定群成员在群里的近期发言;
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
@@ -57,12 +56,12 @@ Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支
## 安全与边界
- Hub 在主进程内运行,不开放本地监听端口;它不会把数据库暴露到公网;
- Hub 使用本机通信,不把数据库直接暴露到公网;
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
- Hub 理解请求或生成总结时,会调用你在“设置 → AI 模型”配置的 Provider;
- 当前实时入口只处理文字消息。连接器会归一化收到的消息条目,但 Agent Hub 只把文本条目当作意图处理,尚未为图片、语音、文件和视频提供同等能力;
- 当前没有实现群发、广播、定时任务或通用自主操作微信(定时日报属于「自动化」,不是 Agent Hub);
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider;
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
## 无法连接时
+2 -5
View File
@@ -4,11 +4,9 @@
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
它同时包含**写入型**端点:生成报告并渲染 PNG(`/report`)、通过已连接机器人发送微信消息(`/agent/send`)、创建/修改/删除/启停定时日报任务并触发立即执行(`/scheduled-reports*`)。因此这个 Token 相当于本机敏感凭据,而不是一个只读查询键。
## Bearer Token
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。历史变量名 `WECHATEXPLORER_API_TOKEN` 仍被兼容读取,优先级为新变量高于旧变量;当前没有设定旧变量名的移除时间,新配置不要再使用它。
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
- `/api/v1/health` 是公开健康检查;
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
@@ -16,8 +14,7 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
- 文件权限设置为 `0600`;
- 在“API Center”中可以显示、复制和重新生成;
- 重新生成后旧 Token 立即失效;
- 服务端只认这个 Token,**不接受用环境变量覆盖**——Agent 一侧的环境变量只是把 Token 交给 Agent 自己的方式,不是鉴权来源。
- 重新生成后旧 Token 立即失效。
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
+28 -169
View File
@@ -8,9 +8,7 @@
- API 前缀:`/api/v1`
- 默认只监听 loopback;不要把它当作公网服务。
- `/api/v1/health` 无需 Token;其他端点需要 `Authorization: Bearer <TOKEN>`。
- 请求体使用 JSON,单个请求体最大 `1 MiB`;超限返回 `413`。
- 错误响应包含 `requestId`,响应头包含 `X-Request-Id`。客户端可传入 1-128 位的 `[A-Za-z0-9._:-]` 标识,否则服务端会生成 UUID。
- 不支持的 HTTP method 返回 `405` 和 `Allow` 响应头。
- 请求体使用 JSON;响应为 JSON。
## 最小请求
@@ -26,168 +24,31 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。应用生成的安装指令仍会提示:尚未升级的旧配置可以继续读取 `WECHATEXPLORER_API_TOKEN`,但新配置必须使用新变量名;如果两个变量都存在,以新变量为准。当前没有设定旧变量名的移除时间。
Token 由应用生成并保存在本机,**不接受用环境变量覆盖**:Agent 侧的环境变量只是把 Token 传给 Agent 自己的方式,不是服务端的鉴权来源。
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
## 端点
| 方法 | 路径 | 作用 | 参数/请求体 |
| ------ | --------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
| GET | `/api/v1/wechat-personal/send-capability` | 个人微信发送能力状态 | 无 |
| GET | `/api/v1/scheduled-reports` | 定时日报任务列表 | 无 |
| POST | `/api/v1/scheduled-reports` | 创建定时日报任务 | `ScheduledReportApiCreateRequest` JSON |
| GET | `/api/v1/scheduled-reports/{id}` | 查询单个定时日报任务 | 无 |
| PATCH | `/api/v1/scheduled-reports/{id}` | 修改定时日报任务 | `ScheduledReportApiUpdateRequest` JSON |
| DELETE | `/api/v1/scheduled-reports/{id}` | 删除定时日报任务 | 无 |
| POST | `/api/v1/scheduled-reports/{id}/enable` | 启用定时日报任务 | 无 |
| 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` | 兼容占位路由;当前返回 `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 删除。
| 方法 | 路径 | 作用 | 参数/请求体 |
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
### 这些端点与实时机器人有什么关系
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
- `/api/v1/agent/group-report` 由外部 Agent 或脚本主动请求生成群聊总结图片;
- `/api/v1/agent/send` 是受 Bearer Token 保护的开发者/测试发送入口,用于通过已经连接的机器人发送文字或本地图片;它不是任意群发能力,也不是实时消息订阅接口;
- `/api/v1/scheduled-reports*` 会**写入**应用状态:创建、修改、删除、启停定时日报任务,以及立刻执行一次。加上 `/report` 和 `/agent/send`,这个 API 并非只读接口——拿到 Token 就能改配置、生成报告并发送微信消息,请按本机敏感凭据对待;
- `POST /api/v1/scheduled-reports/{id}/run` 与定时触发共用同一条链路:读取群聊 → 生成报告 → 保存 Report History → 尝试发送;
- 当前 API 没有对外暴露实时入站 webhook。微信消息由应用内部的 Agent Hub 和微信连接器接收、处理和回复。
## 时间查询
@@ -222,12 +83,10 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
## 响应与错误
- `200`:请求成功;
- `201`:定时日报任务创建成功;
- `401`:缺少、错误或已失效的 Bearer Token;
- `400`:参数或 JSON 请求体无效;
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
- `403`:浏览器 Origin 不在允许的 loopback 列表;
- `409`:定时日报任务重复(`error === "duplicate"`,响应里会带回已存在的任务),或群聊名称匹配到多个目标(`ambiguous_contact`);
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
- `503`:数据库或 Agent Hub 尚未就绪;
- `500`:服务端处理或报告渲染失败。
@@ -295,12 +154,12 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
| scope | 含义 |
| ----------------------------------------- | -------------------------------------------------------------------- |
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) |
| scope | 含义 |
| ----- | ---- |
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) |
`conversationId` 是会话标识,可用 `/api/v1/resolve` 或 `/api/v1/contact` 得到。`scope` 一旦给出就是**权威边界**:`target` 落在范围之外会被拒绝(`status: "invalid_tool_arguments"`、`constraint: "target_outside_scope"`),不会静默扩大范围;范围里包含多个会话时,`query/messages` 与 `query/conversation-overview` 必须显式指定 `target`(`constraint: "target_required_for_scope"`)。
@@ -327,11 +186,11 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
| 字段 | 含义 |
| ------------------- | ------------------------------------------------------------------- |
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
| 字段 | 含义 |
| ---- | ---- |
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` |
调用方**必须**把 `coverage` 当真:`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说明"这段范围暂时无法确认",**不能**下"没有找到"的结论。索引落后时服务端会自动请求一次追赶同步,但不会让请求无限等待;`freshness.catchUp` 为 `pending` 表示追赶仍在后台进行,稍后重试即可拿到更新的覆盖。
+1 -6
View File
@@ -8,7 +8,7 @@ Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Cod
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 仍可继续使用旧变量 `WECHATEXPLORER_API_TOKEN`(当前没有设定移除时间),但新安装请使用新名称与新变量名。
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量。
## 推荐安装流程
@@ -53,13 +53,8 @@ Reader Skill 可以指导 Agent 使用:
- 指定会话、日期或时间戳范围的聊天记录;
- 群成员快照;
- 结构化日报渲染和按群聊生成总结图片;
- 定时日报任务的查询、创建、修改、启停、删除、立即执行和执行记录;删除不可逆,Skill 要求先列出唯一任务并取得用户明确确认;
- 个人微信发送能力状态查询(`/wechat-personal/send-capability`);
- `query/*` 一组结构化 Query 端点:`messages`、`search`、`message-context`、`conversation-overview`;
- Agent Hub 状态检查与已连接机器人发送测试。这里的发送接口是开发者/测试用途,不是实时机器人入口,也不会让 Reader Skill 自动监听微信消息。
注意这个 API 不只是只读的:`/report`、`/agent/send` 和 `/scheduled-reports*` 会写入状态或真的发出微信消息。
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
## 隐私边界
+35 -57
View File
@@ -4,54 +4,39 @@
```mermaid
flowchart LR
WX["本机微信数据"] --> PARSE["读取与解析"]
PARSE --> ARCHIVE["聊天档案与普通搜索"]
PARSE --> EXPORT["聊天导出"]
PARSE --> IDX["本机索引"]
IDX --> TEXTIDX["聊天记录索引"]
IDX --> IMGIDX["图片文字索引(本机识别)"]
TEXTIDX --> UNDERSTAND["Understand:AI Search / 问问微信"]
IMGIDX --> UNDERSTAND
UNDERSTAND --> PROVIDER["你配置的 AI Provider"]
PROVIDER --> ANSWER["回答与可核对来源"]
PARSE --> REPORTINPUT["整理日报输入"]
REPORTINPUT --> PROVIDER
PROVIDER --> REPORTFILE["本机保存 HTML 与 PNG"]
PARSE --> MONITOR["Monitor:退群监控 / 成员快照"]
MONITOR --> RULE["自动化规则"]
REPORTFILE --> RULE
RULE --> POLICY["Policy"]
POLICY --> GATEWAY["Action Gateway"]
GATEWAY --> CAP["本机发送能力"]
CAP --> AUDIT["执行记录与审计"]
PARSE --> API["Local HTTP API"]
API --> EXTAGENT["外部 Agent / Reader Skill"]
BOT["微信机器人消息"] --> HUB["Agent Hub"]
HUB --> PARSE
HUB --> PROVIDER
A[本机微信数据] --> B[读取与解析]
B --> C[聊天档案与普通搜索]
B --> D[本地知识索引]
D --> E[筛选相关消息]
E --> F[用户配置的 AI Provider]
F --> G[回答与可核对来源]
B --> H[聊天导出]
B --> I[整理日报输入]
I --> F
F --> J[本地保存 HTML 与 PNG]
B --> K[Local HTTP API]
K --> L[外部 Agent]
M[微信机器人消息] --> N[Agent Hub]
N --> B
N --> F
B --> O[Monitor / Snapshot]
O --> P[Proposed Action]
F --> P
P --> Q[Policy]
Q --> R[Action Gateway]
R --> S[Personal WeChat Send Capability]
S --> T[Action Audit / Logs]
```
## Remember → 图片文字 → Understand → Monitor → Act
## Remember → Understand → Monitor → Act
TraceMemo 的工作方式可以概括为:
```text
Remember → 图片文字 → Understand → Monitor → Act
Remember → Understand → Monitor → Act
```
- **Remember**:读取并解析本机微信数据,建立聊天档案、普通搜索和导出。
- **图片文字**:在本机识别图片里的文字,把截图、公告、报价图也变成可检索的内容。这一步不联网。
- **Understand**:Knowledge、AI Search / 问问微信、群聊日报。需要模型时,只把完成这次任务所需的受控上下文交给 Provider。
- **Monitor**:用成员快照对比发现群成员变化,产出成员退出事件。
- **Act**:自动化规则把前面的步骤串起来(定时日报、退群通知);动作经过统一执行边界,并留下执行记录。
回答和动作结果都应能回到来源或记录核对。
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
## 退群监控
@@ -61,9 +46,7 @@ Remember → 图片文字 → Understand → Monitor → Act
Current Membership → Snapshot Diff → Member Event
```
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。监控关闭期间发生的变化,不会在重新开启后补报。
成员退出事件同时是「自动化」里「退群通知」规则的触发条件。
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
## 动作执行与审计
@@ -75,20 +58,17 @@ Feature → Policy → Gateway → Capability → Execution → Audit
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
这些动作统一由「自动化」管理,当前有三类规则:**@我生成日报**、**定时日报**、**退群通知**。发送目标支持当前群聊、文件传输助手、自己、指定好友,不是任意群发。
## 哪些步骤在本机
- 微信数据库读取与解析;
- 聊天档案浏览和普通搜索;
- Knowledge 索引与增量同步;
- 图片文字索引:识别图片中的文字完全在本机进行,原始图片不会因为本地识别而上传;
- 离线语音转写;
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
## 哪些步骤可能调用外部服务
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库,本机 OCR、离线语音转写和普通搜索也不会触发外发。
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
@@ -96,15 +76,13 @@ Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生
## 产品名词和用户任务的对应关系
| 用户想做什么 | 产品中可能看到的名称 |
| ------------------------------ | ---------------------------- |
| 让 AI 找相关聊天 | AI Search、Retrieval |
| 让答案能回到原消息 | Evidence、Citation |
| 查看 AI 查找过程 | Search Trace |
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
| 搜到截图、公告图里写过的文字 | 图片文字索引、本机 OCR |
| 让日报、退群通知按规则自动执行 | 自动化、Policy、执行记录 |
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
| 让微信机器人调用本机能力 | Agent Hub |
| 用户想做什么 | 产品中可能看到的名称 |
| ------------------------ | ---------------------------- |
| 让 AI 找相关聊天 | AI Search、Retrieval |
| 让答案能回到原消息 | Evidence、Citation |
| 查看 AI 查找过程 | Search Trace |
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
| 让微信机器人调用本机能力 | Agent Hub |
先按任务使用,再在需要排查或开发集成时阅读术语。
@@ -1,308 +0,0 @@
# 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 能力需在后续实现集成测试中验证;静态代码审计无法替代真实数据库/微信连接的运行时验证。
+13 -23
View File
@@ -33,24 +33,17 @@ pnpm test:e2e:build
## 代码变更对应文档
| 代码区域 | 需要同步检查的文档 |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
| `src/main/services/system-ocr-service.ts`、`image-text-index-service.ts` | `user-guide/knowledge.md`、`concepts/how-it-works.md`、`user-guide/privacy.md` |
| `src/main/services/image-insight-service.ts`、AI Provider | `user-guide/report.md`、`user-guide/privacy.md` |
| `src/shared/automation.ts`、自动化服务与执行网关 | `user-guide/report.md`、`concepts/how-it-works.md`、`docs/README.md` |
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
两条容易被写错的边界:
- **防撤回已下线**(`src/main/services/recall-archive-service.ts` 保留但不再启动):设置入口隐藏,`recallProtectionEnabled` 在所有读写路径上被强制收敛为 `false`。不要把它写回用户指南。
- **图片文字索引(本机 OCR)与图片理解(需要 Provider)是两条不同的路径**:前者写入本地索引、能被搜索,且不联网;后者只在日报和设置里的模型检测中使用。改其中一条时不要把另一条的隐私口径带过去。
| 代码区域 | 需要同步检查的文档 |
| --------------------------------------------------------- | ------------------------------------------------------- |
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
| `src/main/services/recall-archive-service.ts` | `user-guide/privacy.md` |
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
## 文档检查
@@ -58,10 +51,7 @@ pnpm test:e2e:build
```bash
git diff --check
# 过时版本号、旧品牌名、旧结构叙述、MCP 误解
rg -n "v2\.1\.7|2\.4\.0|v2\.2\.0 兼容期|无鉴权|mcpServers" README.md docs --glob '*.md' --glob '!development/overview.md'
# 不存在的产品结构(定时日报已并入自动化)
rg -n "日报 → 定时日报|Monitor / Automation" README.md docs --glob '*.md'
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
```
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。发版前额外确认 `README.md` 里的版本号与 `package.json` 的 `version` 一致。
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
+27 -49
View File
@@ -1,6 +1,6 @@
---
name: tracememo-reader
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体,并管理定时日报任务。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结、查询或修改定时日报时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
---
# TraceMemo Reader
@@ -27,39 +27,31 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
## 端点速查
| 方法 | 路径 | 用途 |
| ------ | ------------------------------------------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
| GET | `/recent_chat` | 最近会话;可传 `limit` |
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| POST | `/query/messages` | 按目标与时间范围取消息(结构化,不调用 AI) |
| POST | `/query/search` | 受限语义关键词检索(依赖本地索引,见 freshness) |
| POST | `/query/message-context` | 用 `messageRef` 取某条消息的前后文 |
| POST | `/query/conversation-overview` | 按会话与时间范围提取可总结的证据 |
| GET | `/query/capabilities` | Query 端点能力目录 |
| GET | `/wechat-personal/send-capability` | 个人微信发送能力状态(文字 / 图片 / 语音) |
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
| POST | `/scheduled-reports` | 创建定时日报任务 |
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
| POST | `/scheduled-reports/executions/:executionId/retry-send` | 复用已有 PNG 重试发送 |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
| POST | `/agent/send` | 已连接机器人发送测试(文字或本地图片) |
这个 API **不只是只读的**:`/report` 会渲染并写文件,`/agent/send` 会真的发出微信消息,`/scheduled-reports*` 会创建、修改、删除或立刻执行定时任务。这些调用都要先确认用户意图;`DELETE` 与 `/agent/send` 尤其需要用户明确确认。
| 方法 | 路径 | 用途 |
| ------ | ----------------------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
| GET | `/recent_chat` | 最近会话;可传 `limit` |
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
| POST | `/scheduled-reports` | 创建定时日报任务 |
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
| POST | `/agent/send` | 已连接机器人发送测试 |
## 定时日报管理
@@ -86,7 +78,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
}
```
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态不是 `ready` 时(`unsupported`、`unconfigured`、`needs_binding`、`initializing` 或 `error`),直接说明需要先在 TraceMemo 的“设置 → 发送能力”里完成个人微信绑定和能力检测,不要继续创建任务。
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
### 查看、修改和执行
@@ -110,20 +102,6 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- 根据多条消息整理出的总结;
- 没有来源支持的推断。
## 结构化查询(query/\*)
需要按目标 + 时间范围稳定取数时,优先使用 `query/*`,而不是自己拼 `chatlog`:
- `query/messages`:按 `target`、`timeRange`、`direction`、`messageTypes` 取消息;
- `query/search`:受限语义关键词检索,依赖本地索引;
- `query/message-context`:用返回的 `messageRef` 取前后文;
- `query/conversation-overview`:按会话与时间范围提取可总结的证据。
两个要点:
- `messageRef` 是服务端生成的不透明引用,**不要**自行构造 wxid、md5 或数据库路径;
- `query/search` 依赖异步建立的本地索引。`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说“这段范围暂时无法确认”,**不能**下“没有找到”的结论。
## 媒体消息
当 `/chatlog` 返回图片消息时:
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2026 yincongcyincong
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+28
View File
@@ -0,0 +1,28 @@
# wechat_chatter / OneBot 第三方组件说明
TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件:
- 项目:`yincongcyincong/wechat_chatter`
- 上游仓库:https://github.com/yincongcyincong/wechat_chatter
- 当前运行时版本:`v0.0.18`
- 运行时文件:`onebot_mac_arm64.tar.gz`
- 许可证:GNU General Public License version 3(GPL-3.0)
- 上游版权:Copyright (C) 2026 yincongcyincong
- TraceMemo 修改日期:2026-08-17
## 集成方式
OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。
运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。
## 本地修改
为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于:
- `scripts/prepare-wechat-chatter-runtime.cjs`
- `src/main/services/personal-wechat-runtime-manager.ts`
补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。
GPL-3.0 的完整文本见本目录下的 `LICENSE`。
+3 -7
View File
@@ -33,7 +33,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
- 你需要有权访问要读取的微信账号和聊天数据。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。「图片文字索引」不在此列——它在本机识别图片里的文字,不需要配置 AI。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
@@ -134,7 +134,6 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
- [生成群聊日报或总结](./report.md)
- [转写微信语音](./voice.md)
- [导出聊天档案](./export.md)
- [让日报、退群通知按规则自动运行](../README.md#日报与自动化)
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
- [让外部 Agent 查询微信历史](../agent/overview.md)
@@ -160,17 +159,14 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
## 8. 需要配置 AI 吗?
不一定。浏览聊天、普通关键词搜索、建立本地知识库、图片文字识别、离线语音转写和导出都不要求在线 AI 服务。
不一定。浏览聊天、普通关键词搜索、建立本地知识库和导出不要求在线 AI 服务。
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
这两种情况容易混淆:**本机识别图片里的文字**(图片文字索引)不联网、不需要 AI 服务;**让模型看图并回答**(图片理解)才需要配置 AI 服务。
## 9. 数据和隐私的最低须知
- 微信数据库、聊天解析、本地索引和图片文字识别默认留在本机。
- 微信数据库、聊天解析和本地索引默认留在本机。
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
- 图片文字索引只在本机识别,原始图片不会因为本地识别而上传;识别出的文字会进入本地索引,供搜索和“问问微信”使用。
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
+8 -22
View File
@@ -24,13 +24,13 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
### 状态怎么读
| 状态 | 含义 |
| ------------------- | ------------------------------------------------ |
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
| 状态 | 含义 |
| ---- | ---- |
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
@@ -38,21 +38,6 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
## 图片文字索引(另一份索引)
本地索引其实有两份,彼此独立:
- **聊天记录索引**(也就是上面说的 Knowledge):索引文字消息,用于跨会话、跨时间查找;
- **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图等),把识别结果也变成可搜索的文字。
“独立”的意思是:聊天记录索引建好了,并不代表图片里的文字就搜得到。建立图片文字索引后,可以在“问问微信”里直接搜截图或公告图里写过的词。
图片文字索引只在本机识别,原始图片不会因为本地识别而上传。它**不等于“图片理解”**:识别文字不联网、不需要 AI 服务;而让模型看图并回答属于图片理解,需要配置 AI 服务,走的是另一条路径。
识别失败的图片可以单独重试,也有“只重建搜索索引、不重新识别图片”的修复入口——修索引不需要重跑几万张图。
两个索引都可以在“设置 → 本地索引”里集中查看状态、建立、同步和清理。
## 账号隔离
每个微信账号使用独立的本地索引。切换账号时,应用不会把一个账号的索引混入另一个账号的搜索结果。
@@ -73,3 +58,4 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
## 产品术语(可选)
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
+2 -10
View File
@@ -9,7 +9,6 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
- 读取和解析微信数据库;
- 聊天档案浏览和普通关键词搜索;
- 本地 Knowledge 索引及其账号隔离;
- 图片文字索引:识别图片中的文字在本机完成,原始图片不会因为本地识别而上传;
- 离线语音转写;
- 导出文件生成和本地日报历史。
@@ -17,7 +16,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
## 什么时候会请求外部服务
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。图片文字索引、离线语音转写、档案浏览和普通搜索不会触发这一步。当前设置页给出的边界是:
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
- 当前用户问题;
- 受控检索所需的有限上下文;
@@ -29,14 +28,7 @@ Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的
## 语音和媒体
离线语音转写在本机进行。
图片有两条完全不同的路径,不要混为一谈:
- **图片文字索引**:在本机识别图片里的文字,产出的是本地索引数据;原始图片不会因为这一步被上传,也不需要配置 AI 服务。
- **图片理解**:属于 AI 功能。只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。
无法读取的媒体不会被自动“猜出来”。
离线语音转写在本机进行。图片理解属于 AI 功能:只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。无法读取的媒体不会被自动“猜出来”。
## Local HTTP API
+2 -6
View File
@@ -33,11 +33,9 @@
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
## 定时日报(在自动化里)
## 定时日报
定时日报现在是「自动化」里的一种规则,不再单独占一个页面:打开一级导航的「自动化」,新建或编辑一条「定时日报」规则,选择群聊、执行时间、日报范围、消息类型和发送目标。日报页顶部的指引条也会直接跳到自动化。
TraceMemo 会按计划执行:
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
```text
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
@@ -47,8 +45,6 @@ TraceMemo 会按计划执行:
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
发送目标当前支持**当前群聊、文件传输助手、自己、指定好友**——还不是任意群发。
## 让报告更可靠
- 先选正确的群和时间范围;
-16
View File
@@ -1,16 +0,0 @@
extends: ./electron-builder.yml
extraResources:
- from: build/app-update.yml
to: app-update.yml
- from: resources
to: resources
filter:
- '**/*'
- from: docs/skill/tracememo-reader
to: skill/tracememo-reader
filter:
- '**/*'
mac:
target:
- dmg
- zip
+1 -1
View File
@@ -34,7 +34,7 @@ extraResources:
to: resources
filter:
- '**/*'
- '!runtime/darwin-arm64/**'
- '!connectors/wechat-personal/**'
- from: docs/skill/tracememo-reader
to: skill/tracememo-reader
filter:
+3 -8
View File
@@ -1,6 +1,6 @@
{
"name": "tracememo",
"version": "2.5.0",
"version": "2.4.0",
"packageManager": "pnpm@7.33.7",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
"keywords": [
@@ -39,7 +39,7 @@
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
"prepare:wechat-native": "node scripts/prepare-wechat-native-runtime.cjs",
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
"start": "electron-vite preview",
"predev": "node scripts/ensure-electron-binary.cjs",
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
@@ -61,7 +61,6 @@
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
"build:mac:arm64": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"build:mac:arm64:send-runtime": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && cross-env TM_SEND_RUNTIME_BUILD=1 electron-builder --config electron-builder.send-runtime.yml --mac --arm64",
"build:mac:x64": "npm run typecheck && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"release": "npm run release:mac && npm run release:win",
"release:mac": "npm run typecheck && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
@@ -75,10 +74,8 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-darwin-x64": "3.1.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@napi-rs/system-ocr": "1.2.0",
"@napi-rs/system-ocr-darwin-x64": "1.2.0",
"@napi-rs/system-ocr-win32-x64-msvc": "1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11",
@@ -110,9 +107,7 @@
"parse5": "^8.0.0",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"sherpa-onnx-darwin-x64": "1.13.3",
"sherpa-onnx-node": "1.13.3",
"sherpa-onnx-win-x64": "1.13.4",
"silk-wasm": "^3.7.1",
"tailwind-merge": "^3.6.0",
"unzipper": "^0.12.0",
@@ -174,4 +169,4 @@
"ffmpeg-static"
]
}
}
}
+31 -38
View File
@@ -11,10 +11,8 @@ specifiers:
'@electron-toolkit/preload': ^3.0.2
'@electron-toolkit/tsconfig': ^2.0.0
'@electron-toolkit/utils': ^4.0.0
'@koromix/koffi-darwin-x64': 3.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-darwin-x64': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@playwright/test': ^1.62.1
'@radix-ui/react-alert-dialog': ^1.1.23
@@ -74,9 +72,7 @@ specifiers:
react: ^19.2.1
react-dom: ^19.2.1
sass: ^1.102.0
sherpa-onnx-darwin-x64: 1.13.3
sherpa-onnx-node: 1.13.3
sherpa-onnx-win-x64: 1.13.4
silk-wasm: ^3.7.1
tailwind-merge: ^3.6.0
tailwindcss: 3.4.17
@@ -91,10 +87,8 @@ specifiers:
dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-darwin-x64': 3.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-darwin-x64': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
@@ -126,9 +120,7 @@ dependencies:
parse5: 8.0.1
pinyin-pro: 3.29.3
qrcode: 1.5.4
sherpa-onnx-darwin-x64: 1.13.3
sherpa-onnx-node: 1.13.3
sherpa-onnx-win-x64: 1.13.4
silk-wasm: 3.7.1
tailwind-merge: 3.6.0
unzipper: 0.12.5
@@ -1593,6 +1585,7 @@ packages:
cpu: [x64]
os: [darwin]
dev: false
optional: true
/@koromix/koffi-freebsd-arm64/3.1.0:
resolution: {integrity: sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==}
@@ -1696,36 +1689,6 @@ packages:
- supports-color
dev: true
/@napi-rs/system-ocr-darwin-arm64/1.2.0:
resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
dev: false
optional: true
/@napi-rs/system-ocr-darwin-x64/1.2.0:
resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
dev: false
/@napi-rs/system-ocr-win32-arm64-msvc/1.2.0:
resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
dev: false
optional: true
/@napi-rs/system-ocr-win32-x64-msvc/1.2.0:
resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
dev: false
/@napi-rs/system-ocr/1.2.0:
resolution: {integrity: sha512-r0f2xNH6U+sth44qF+lUP+2WuHSGUBAry5KSCNuaLDGRbgslFqeROr/qJJ/fb6AjBp3Ov+CJP5MdrOWoaoM3cw==}
engines: {node: '>= 10'}
@@ -1736,6 +1699,34 @@ packages:
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
dev: false
/@napi-rs/system-ocr-darwin-arm64/1.2.0:
resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==}
cpu: [arm64]
os: [darwin]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-darwin-x64/1.2.0:
resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==}
cpu: [x64]
os: [darwin]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-win32-arm64-msvc/1.2.0:
resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==}
cpu: [arm64]
os: [win32]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-win32-x64-msvc/1.2.0:
resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==}
cpu: [x64]
os: [win32]
engines: {node: '>= 10'}
dev: false
/@nodelib/fs.scandir/2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -7598,6 +7589,7 @@ packages:
cpu: [x64]
os: [darwin]
dev: false
optional: true
/sherpa-onnx-linux-arm64/1.13.4:
resolution: {integrity: sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw==}
@@ -7636,6 +7628,7 @@ packages:
cpu: [x64]
os: [win32]
dev: false
optional: true
/side-channel-list/1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 B

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<rect
x="1.5"
y="1.5"
width="15"
height="15"
rx="3.5"
fill="none"
stroke="#000"
stroke-width="1.75"
/>
<circle cx="9" cy="9" r="2.3" fill="#000" />
</svg>

Before

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

Binary file not shown.
+18 -198
View File
@@ -125,62 +125,13 @@ function validateSystemOcrRuntime(runtimeResources, platform, arch) {
}
}
/**
* koffi 运行期按 `${process.platform}-${process.arch}` 拼出原生包目录名
* (node_modules/koffi/src/koffi/index.cjs:153/175),找不到就直接抛
* "Cannot find the native Koffi module; did you bundle it correctly?"。
* pnpm 7 不支持 supportedArchitectures,会静默跳过外平台可选依赖,所以每个目标平台的
* koffi 原生包都必须在 package.json 里显式声明;这里再兜一层,缺了就让构建失败,
* 而不是发出一个装得上、却打不开 WCDB 的包。
*/
function koffiNativeTarget(platform, arch) {
if (platform === 'win32') {
return arch === 'x64'
? { label: 'Windows', segments: ['@koromix', 'koffi-win32-x64', 'win32_x64', 'koffi.node'] }
: null
}
if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) {
return {
label: 'macOS',
segments: ['@koromix', `koffi-darwin-${arch}`, `darwin_${arch}`, 'koffi.node']
}
}
return null
}
function validateKoffiRuntime(runtimeResources, platform, arch) {
const target = koffiNativeTarget(platform, arch)
if (!target) return
const nativePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
...target.segments
)
if (!existsSync(nativePath)) {
throw new Error(`Missing ${target.label} Koffi native module: ${nativePath}`)
}
}
function normalizeBuilderArch(arch) {
if (typeof arch === 'string') return arch
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
}
function runCodesign(args) {
try {
execFileSync('/usr/bin/codesign', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
const stderr =
error && typeof error === 'object' && 'stderr' in error ? String(error.stderr) : ''
if (stderr.trim() && error instanceof Error) {
error.message += `\n${stderr.trim()}`
}
throw error
}
execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' })
}
function isMacosCodeValid(targetPath, run = runCodesign) {
@@ -223,89 +174,8 @@ function signMacosHelpers(runtimeResources, run = runCodesign) {
return helperPaths
}
/**
* codesign 只把这些位置当作「嵌套代码」并要求它们先各自签好,才肯签外层 app。
* 只遍历这一组根目录,而不是整个 bundle:Contents/Resources 下的
* app.asar.unpacked 里成千上万个原生文件不属于嵌套代码,逐个签既慢又无意义。
*/
const MACOS_CODE_LOCATIONS = [
'Frameworks',
'MacOS',
'PlugIns',
'XPCServices',
'Helpers',
'Library/LoginItems'
]
function collectNestedMacosCode(dir, depth, targets) {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name)
// framework 里的 Mantle -> Versions/Current/Mantle 这类符号链接指向真实文件,
// 真实文件会在更深的层级被走到;这里跳过以免重复签名。
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
if (/\.(app|framework|xpc)$/.test(entry.name)) {
targets.push({ path: entryPath, depth, bundle: true })
}
collectNestedMacosCode(entryPath, depth + 1, targets)
continue
}
if (!entry.isFile()) continue
if (readBinaryArchitectures(entryPath).length === 0) continue
targets.push({ path: entryPath, depth, bundle: false })
}
}
/**
* 返回嵌套代码的签名顺序:深度大的先签(framework 内部的 dylib、无扩展名的
* crashpad handler 先于 framework 本身,helper 的可执行文件先于 helper app),
* 同深度时文件先于 bundle。
*/
function findNestedMacosCodePaths(appBundlePath) {
const targets = []
for (const location of MACOS_CODE_LOCATIONS) {
const root = path.join(appBundlePath, 'Contents', ...location.split('/'))
if (existsSync(root)) collectNestedMacosCode(root, 1, targets)
}
return targets
.map((target, index) => ({ ...target, index }))
.sort((a, b) => {
if (a.depth !== b.depth) return b.depth - a.depth
if (a.bundle !== b.bundle) return a.bundle ? 1 : -1
return a.index - b.index
})
.map((target) => target.path)
}
/**
* Electron 43.1.0 的 darwin-x64 官方 zip(sha256 与上游 SHASUMS256.txt 一致)
* 里所有嵌套 Mach-O 都是未签名状态,darwin-arm64 那份则是 linker-signed。
* codesign 签外层 bundle 时要求子组件已签,否则直接报
* "code object is not signed at all" + "In subcomponent: ...",
* 所以 x64 出包时只签外层必然失败,必须先由内向外补签一遍。
*
* 这里不采用 `--deep`(Apple 已标记 deprecated):它会把外层的签名选项套用到
* 所有子组件上,将来接上 Developer ID + entitlements 时会把 app 的 entitlements
* 一并套到 helper 上,属于已知的坑。
*/
function signMacosAppBundle(appBundlePath, run = runCodesign) {
if (isMacosCodeValid(appBundlePath, run)) return appBundlePath
for (const nestedPath of findNestedMacosCodePaths(appBundlePath)) {
try {
run(['--force', '--sign', '-', nestedPath])
} catch (error) {
throw new Error(
'macOS nested code signing failed: ' + path.relative(appBundlePath, nestedPath),
{ cause: error }
)
}
}
run(['--force', '--sign', '-', appBundlePath])
try {
run(['--verify', '--strict', appBundlePath])
@@ -455,70 +325,9 @@ function pruneForeignArchConnectors(runtimeResources, platform, arch) {
return removed
}
/**
* 微信发送运行时打包边界
*/
const SEND_RUNTIME_RELATIVE = ['resources', 'runtime', 'darwin-arm64']
const SEND_RUNTIME_ENTRY = 'tm-wechat-host'
function sendRuntimeLocations(runtimeResources) {
return [
path.join(runtimeResources, ...SEND_RUNTIME_RELATIVE),
path.join(runtimeResources, 'app.asar.unpacked', ...SEND_RUNTIME_RELATIVE)
]
}
function findSendRuntime(runtimeResources) {
return (
sendRuntimeLocations(runtimeResources).find((directory) =>
existsSync(path.join(directory, SEND_RUNTIME_ENTRY))
) || null
)
}
function isSendRuntimeBuild() {
return process.env.TM_SEND_RUNTIME_BUILD === '1'
}
function enforceSendRuntimeBoundary(
runtimeResources,
platform,
bundlesSendRuntime = isSendRuntimeBuild()
) {
if (platform !== 'darwin') return null
const found = findSendRuntime(runtimeResources)
if (bundlesSendRuntime) {
if (!found) {
throw new Error(
'This macOS build requires the WeChat send runtime but resources/runtime/darwin-arm64 is missing. ' +
'Run `pnpm prepare:wechat-native` first, or point TM_NATIVE_RUNTIME_DIR at the artifact.'
)
}
return found
}
if (found) {
throw new Error(
'macOS bundle must not include the WeChat send runtime: ' +
found +
'. Build with `pnpm build:mac:arm64:send-runtime` (TM_SEND_RUNTIME_BUILD=1) when it is required, ' +
'or fix the resources filter in electron-builder.yml.'
)
}
return null
}
exports.default = async function afterPack(context) {
const runtimeResources = getRuntimeResources(context)
const arch = normalizeBuilderArch(context.arch)
// 边界先判,越早失败越好。
const sendRuntime = enforceSendRuntimeBoundary(runtimeResources, context.electronPlatformName)
if (context.electronPlatformName === 'darwin') {
console.log(
sendRuntime
? `[afterPack] send runtime bundled at ${sendRuntime}`
: '[afterPack] send runtime excluded'
)
}
validateAsarRuntimeDependencies(runtimeResources)
validateReaderSkillRuntime(runtimeResources)
validateSilkWasmRuntime(runtimeResources)
@@ -531,7 +340,6 @@ exports.default = async function afterPack(context) {
)
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
validateKoffiRuntime(runtimeResources, context.electronPlatformName, arch)
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
@@ -544,6 +352,23 @@ exports.default = async function afterPack(context) {
const productName = context.packager.appInfo.productFilename
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
}
if (context.electronPlatformName === 'win32') {
const koffiNative = path.join(
context.appOutDir,
'resources',
'app.asar.unpacked',
'node_modules',
'@koromix',
'koffi-win32-x64',
'win32_x64',
'koffi.node'
)
if (!existsSync(koffiNative)) {
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
}
return
}
}
exports.getRuntimeResources = getRuntimeResources
@@ -553,7 +378,6 @@ exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
exports.validateKoffiRuntime = validateKoffiRuntime
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
@@ -562,7 +386,3 @@ exports.findMacosHelperPaths = findMacosHelperPaths
exports.isMacosCodeValid = isMacosCodeValid
exports.signMacosHelpers = signMacosHelpers
exports.signMacosAppBundle = signMacosAppBundle
exports.findNestedMacosCodePaths = findNestedMacosCodePaths
exports.sendRuntimeLocations = sendRuntimeLocations
exports.findSendRuntime = findSendRuntime
exports.enforceSendRuntimeBoundary = enforceSendRuntimeBoundary
+436
View File
@@ -0,0 +1,436 @@
/*
* wechat_chatter runtime integration
*
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* Upstream license: GNU General Public License version 3 (GPL-3.0)
*
* This file applies local compatibility patches to the upstream
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const release = 'v0.0.18'
const asset = 'onebot_mac_arm64.tar.gz'
const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}`
const projectRoot = path.resolve(__dirname, '..')
const outputDir = path.join(
projectRoot,
'resources',
'connectors',
'wechat-personal',
'darwin-arm64'
)
const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`)
const appleSilicon =
process.platform === 'darwin' &&
execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1'
if (!appleSilicon) {
throw new Error('个人微信发送运行时当前仅支持 macOS arm64')
}
fs.mkdirSync(outputDir, { recursive: true })
let archiveReady = false
if (fs.existsSync(archive)) {
try {
execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' })
archiveReady = true
console.log(`[wechat-personal] 复用已下载归档:${archive}`)
} catch {
// The archive is partial or invalid; curl will resume it below.
}
}
if (!archiveReady) {
console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`)
execFileSync(
'/usr/bin/curl',
['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url],
{ stdio: 'inherit' }
)
}
const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-'))
console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`)
try {
execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' })
fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true })
fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true })
fs.copyFileSync(
path.join(extractionDir, 'onebot', 'script.js'),
path.join(outputDir, 'onebot', 'script.js')
)
fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), {
recursive: true,
force: true
})
const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`)
fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable)
fs.chmodSync(stagedExecutable, 0o755)
fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot'))
} finally {
fs.rmSync(extractionDir, { recursive: true, force: true })
}
const executable = path.join(outputDir, 'onebot', 'onebot')
const script = path.join(outputDir, 'onebot', 'script.js')
const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json')
for (const required of [executable, script, config]) {
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
}
function patchPerSendPayload(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
activeTriggerX1Payload = Memory.alloc(payloadData.length);
activeTriggerX1Payload.writeByteArray(payloadData);
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
triggerX1Payload.writeByteArray(payloadData);
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
source = source
.replace(
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;',
'var triggerX1Payload;\nvar triggerX0;'
)
.replace(activeSend, upstreamSend)
.replace(
' MMStartTask(triggerX0, activeTriggerX1Payload);',
' MMStartTask(triggerX0, triggerX1Payload);'
)
.replace(
' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
)
.replace(
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn',
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn'
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已恢复原生发送 payload 布局')
}
function patchSendContextCapture(scriptPath, strict = true) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('function isLikelySendContext(')) return
const original = `function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) {
return
}
triggerX0 = this.context.x0;
triggerX1Payload = this.context.x1;
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
try {
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
var manager = readPointerIfReadable(candidateX0.add(0x18));
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
return !manager.equals(ptr(0));
} catch (e) {
console.error("[debug] StartTask candidate inspect failed: " + e);
return false;
}
}
function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) return;
var candidateX0 = this.context.x0;
var candidateX1 = this.context.x1;
if (!isLikelySendContext(candidateX0, candidateX1)) return;
triggerX0 = candidateX0;
triggerX1Payload = candidateX1;
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
if (!source.includes(original)) {
if (strict) throw new Error('无法定位 StartTask 上下文 Hook')
return
}
source = source.replace(original, patched)
fs.writeFileSync(scriptPath, source)
}
function patchVoiceAudioBuffer(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
if (!source.includes(staticAllocation)) {
throw new Error('无法定位 wechat_chatter 语音缓冲区')
}
source = source.replace(
staticAllocation,
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
)
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
}
source = source.replace(
audioLengthMarker,
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
}
function patchImageHookReadiness(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
source.includes('图片上传 Hook Setup Complete')
)
return
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
\t\t\t}
\t\t\tuploadGlobalX0 = capturedUploadX0;`
if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置')
source = source.replace(original, patched)
source = source.replace(
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
}
// Backport of wechat_chatter PR #36 by @Leslielu:
// https://github.com/yincongcyincong/wechat_chatter/pull/36
// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses.
// macOS WeChat 4.1.11.53, located and verified by TraceMemo
function patchCdnColdStart(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('function resolveCdnManager()')) return
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容')
source = source.replace(initAddresses, patchedInitAddresses)
const downloadChunkEnd = `}
function fillUploadX1AndStart`
const resolver = `}
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
function resolveCdnManager() {
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
return ptr(0);
}
try {
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
var strDefault = Memory.alloc(24);
strDefault.writeUtf8String("default");
strDefault.add(0x17).writeU8(7);
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
var svc = getService(strDefault);
if (!isReadablePointer(svc)) {
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
return ptr(0);
}
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
var ctx = getCtx(svc);
if (!isReadablePointer(ctx)) {
console.error("[!] CdnManager getter 返回不可读: " + ctx);
return ptr(0);
}
var mgr = readPointerIfReadable(ctx.add(0x40));
if (!isReadablePointer(mgr)) {
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
return ptr(0);
}
return mgr;
} catch (e) {
console.error("[!] resolveCdnManager 异常: " + e);
return ptr(0);
}
}
function ensureCdnManagerX0() {
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
}
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = uploadGlobalX0;
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
}
if (uploadGlobalX0.equals(ptr(0))) {
var mgr = resolveCdnManager();
if (!mgr.equals(ptr(0))) {
uploadGlobalX0 = mgr;
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = mgr;
}
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
}
}
return !uploadGlobalX0.equals(ptr(0));
}
function fillUploadX1AndStart`
if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
source = source.replace(downloadChunkEnd, resolver)
const declarations = 'var uploadImageAddr;\n'
const patchedDeclarations =
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明')
source = source.replace(declarations, patchedDeclarations)
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口')
source = source.replace(uploadGuard, patchedUploadGuard)
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口')
source = source.replace(voiceGuard, patchedVoiceGuard)
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
downloadGlobalX0 = capturedUploadX0;
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
}`
if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook')
source = source.replace(uploadHook, patchedUploadHook)
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
}`
if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook')
source = source.replace(downloadHook, patchedDownloadHook)
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0) {`
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (!downloadGlobalX0) {`
if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口')
source = source.replace(downloadGuard, patchedDownloadGuard)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁')
}
function patchCdnColdStartConfig(configPath) {
const source = fs.readFileSync(configPath, 'utf8')
let config
try {
config = JSON.parse(source)
} catch {
throw new Error('4.1.11.53 版本配置不是有效 JSON')
}
config.cdnGetServiceAddr = '0x50a15d0'
config.cdnManagerGetterAddr = '0x5259290'
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址')
}
function patchWechatCoreModuleBase(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('WeChat core module base:')) return
const initMarker = 'function initAddresses() {'
const initIndex = source.indexOf(initMarker)
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
throw new Error('无法定位 wechat_chatter 基址扫描逻辑')
}
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
var module = Process.enumerateModules().find(function(m) {
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
});
if (!module) {
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
}
var moduleBase = module.base;
var baseAddr = moduleBase;
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
setImmediate(initAddresses);
`
source = patchedHeader + source.slice(initIndex)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用微信核心模块基址补丁')
}
function addModifiedWorkNotice(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
const notice = `/*
* TraceMemo wechat_chatter compatibility modifications
* Modified: 2026-08-17
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
* These modifications are not provided by the upstream author.
*/
`
source = notice + source
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已写入 GPL 修改声明')
}
patchWechatCoreModuleBase(script)
patchPerSendPayload(script)
patchSendContextCapture(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script)
patchCdnColdStartConfig(config)
patchCdnColdStart(script)
addModifiedWorkNotice(script)
fs.chmodSync(executable, 0o755)
console.log('[wechat-personal] 运行时准备完成')
-201
View File
@@ -1,201 +0,0 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable @typescript-eslint/no-require-imports */
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const REQUIRED_FILES = ['tm-wechat-host', 'libtmwechat.dylib', 'runtime-manifest.json']
function parseArgs(argv) {
const result = {}
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (value === '--source' || value === '--target') {
if (!argv[index + 1]) throw new Error(`${value} requires a path`)
result[value.slice(2)] = argv[index + 1]
index += 1
} else {
throw new Error(`Unknown option: ${value}`)
}
}
return result
}
function sha256(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
}
function listFiles(root) {
const files = []
function visit(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name)
if (entry.isDirectory()) visit(absolute)
else if (entry.isFile()) files.push(path.relative(root, absolute))
}
}
visit(root)
return files.sort()
}
function assertMachOArm64(filePath, label) {
const buffer = fs.readFileSync(filePath)
if (buffer.length < 8 || buffer.readUInt32LE(0) !== 0xfeedfacf) {
throw new Error(`${label} is not a 64-bit Mach-O binary: ${filePath}`)
}
if (buffer.readUInt32LE(4) !== 0x0100000c) {
throw new Error(`${label} is not arm64: ${filePath}`)
}
}
function readAndValidateArtifact(sourceDir) {
if (!fs.existsSync(sourceDir)) {
throw new Error('native runtime artifact not found: build the macOS runtime artifact first')
}
const actualFiles = listFiles(sourceDir)
const expectedFiles = [...REQUIRED_FILES].sort()
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error(`native runtime artifact has an unexpected tree: ${actualFiles.join(', ')}`)
}
const manifestPath = path.join(sourceDir, 'runtime-manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
if (manifest.runtime !== 'tm-wechat-native') throw new Error('Unexpected runtime name')
if (manifest.platform !== 'darwin-arm64') throw new Error('Runtime platform must be darwin-arm64')
if (manifest.architecture !== 'arm64') throw new Error('Runtime architecture must be arm64')
if (manifest.protocolVersion !== 1) throw new Error('Unsupported runtime protocolVersion')
if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) {
throw new Error('Runtime manifest has no valid tmSendSourceSha256')
}
if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) {
throw new Error('Runtime manifest has no valid addressProfileSha256')
}
const capabilities = new Set(manifest.capabilities || [])
for (const capability of ['text', 'image', 'voice']) {
if (!capabilities.has(capability)) throw new Error(`Runtime capability missing: ${capability}`)
}
if (
!Array.isArray(manifest.supportedWechatVersions) ||
manifest.supportedWechatVersions.length === 0
) {
throw new Error('Runtime manifest has no supportedWechatVersions')
}
for (const relative of REQUIRED_FILES) {
const absolute = path.join(sourceDir, relative)
if (!fs.statSync(absolute).isFile()) throw new Error(`Runtime file missing: ${relative}`)
}
const hostPath = path.join(sourceDir, 'tm-wechat-host')
const dylibPath = path.join(sourceDir, 'libtmwechat.dylib')
assertMachOArm64(hostPath, 'tm-wechat-host')
assertMachOArm64(dylibPath, 'libtmwechat.dylib')
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) {
throw new Error('Embedded agent hash is not present in libtmwechat.dylib')
}
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) {
throw new Error('Embedded address profile hash is not present in libtmwechat.dylib')
}
return {
manifest,
hashes: Object.fromEntries(
REQUIRED_FILES.map((file) => [file, sha256(path.join(sourceDir, file))])
)
}
}
function syncArtifact(sourceDir, targetDir) {
const source = readAndValidateArtifact(sourceDir)
const parent = path.dirname(targetDir)
const name = path.basename(targetDir)
const staging = path.join(parent, `.${name}.prepare-${process.pid}`)
const backup = path.join(parent, `.${name}.backup-${process.pid}`)
fs.mkdirSync(parent, { recursive: true })
fs.rmSync(staging, { recursive: true, force: true })
fs.rmSync(backup, { recursive: true, force: true })
fs.mkdirSync(staging, { recursive: true })
try {
for (const relative of REQUIRED_FILES) {
const destination = path.join(staging, relative)
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.copyFileSync(path.join(sourceDir, relative), destination)
}
fs.chmodSync(path.join(staging, 'tm-wechat-host'), 0o755)
const staged = readAndValidateArtifact(staging)
for (const relative of REQUIRED_FILES) {
if (source.hashes[relative] !== staged.hashes[relative]) {
throw new Error(`Packaged runtime hash mismatch: ${relative}`)
}
}
if (fs.existsSync(targetDir)) fs.renameSync(targetDir, backup)
try {
fs.renameSync(staging, targetDir)
} catch (error) {
if (fs.existsSync(backup)) fs.renameSync(backup, targetDir)
throw error
}
fs.rmSync(backup, { recursive: true, force: true })
return staged
} finally {
fs.rmSync(staging, { recursive: true, force: true })
}
}
function resolveSource(explicit) {
if (explicit) return path.resolve(explicit)
const fromEnv = String(process.env.TM_NATIVE_RUNTIME_DIR || '').trim()
if (fromEnv) return path.resolve(fromEnv)
// 本机路径写在这里即可,该文件不进仓库。
const localConfig = path.join(__dirname, '..', '.native-runtime-source')
if (fs.existsSync(localConfig)) {
const configured = fs.readFileSync(localConfig, 'utf8').trim()
if (configured) return path.resolve(configured)
}
throw new Error(
'runtime artifact source is required: pass --source <dir>, set TM_NATIVE_RUNTIME_DIR, ' +
'or write the path into .native-runtime-source'
)
}
function main() {
const projectRoot = path.resolve(__dirname, '..')
const args = parseArgs(process.argv.slice(2))
const sourceDir = resolveSource(args.source)
const targetDir = path.resolve(
args.target || path.join(projectRoot, 'resources', 'runtime', 'darwin-arm64')
)
const result = syncArtifact(sourceDir, targetDir)
console.log(`[prepare-wechat-native] source: ${sourceDir}`)
console.log(`[prepare-wechat-native] target: ${targetDir}`)
console.log(
`[prepare-wechat-native] runtime: ${result.manifest.runtime}/${result.manifest.version}`
)
console.log(`[prepare-wechat-native] agent: ${result.manifest.tmSendSourceSha256}`)
console.log(`[prepare-wechat-native] address profile: ${result.manifest.addressProfileSha256}`)
console.log('[prepare-wechat-native] files: 3')
}
module.exports = {
REQUIRED_FILES,
assertMachOArm64,
readAndValidateArtifact,
syncArtifact
}
if (require.main === module) {
try {
main()
} catch (error) {
console.error(
`[prepare-wechat-native] ${error instanceof Error ? error.message : String(error)}`
)
process.exitCode = 1
}
}
+68 -425
View File
@@ -1,10 +1,8 @@
import crypto from 'crypto'
import http, { IncomingMessage, ServerResponse, Server } from 'http'
import { app } from 'electron'
import {
isReady,
listContacts,
listContactsAsync,
listMessages,
getGroupSnapshot,
listRecentChat,
@@ -29,11 +27,6 @@ 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
@@ -51,11 +44,6 @@ 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>
@@ -65,56 +53,21 @@ 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
}
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(', ')}`)
}
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
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)
const body = JSON.stringify(payload, null, 2)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
...(requestId ? { 'X-Request-Id': requestId } : {})
'Cache-Control': 'no-store'
})
res.end(body)
}
@@ -137,8 +90,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, HEAD, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
return true
}
@@ -158,20 +111,6 @@ 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 } : {}) })
}
@@ -181,8 +120,7 @@ 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-Request-Id': String(res.getHeader('X-Request-Id') || '')
'X-Content-Type-Options': 'nosniff'
})
res.end(result.buffer)
}
@@ -197,26 +135,10 @@ function sanitizeChatlogMessage(message: Record<string, unknown>): Record<string
return { ...message, contentData: safeContentData }
}
function readBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<string> {
function readBody(req: IncomingMessage): 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[] = []
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('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
req.on('error', reject)
})
@@ -285,26 +207,18 @@ 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': withMethods(['GET'], ({ res }) => {
'/api/v1/health': ({ res }) => {
sendJson(res, 200, {
ok: true,
ready: isReady(),
service: 'TraceMemo Reader',
version: getApplicationVersion(),
version: '1.0.0',
timestamp: new Date().toISOString()
})
}),
},
'/api/v1/current_time': withMethods(['GET'], ({ res }) => {
'/api/v1/current_time': ({ res }) => {
const now = new Date()
sendJson(res, 200, {
time: now.toISOString(),
@@ -314,28 +228,23 @@ const routes: Record<string, RouteHandler> = {
now.getDate()
).padStart(2, '0')}`
})
}),
},
'/api/v1/contact': withMethods(['GET'], async ({ res, url }) => {
'/api/v1/contact': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const filter = url.searchParams.get('filter') || undefined
const type = url.searchParams.get('type') || undefined
// Use the hydrated data source: on macOS the session cache only carries raw
// ids until display names / contact identities are hydrated, so the sync
// `listContacts` would miss nickname and remark matches (Issue #51).
let contacts = await listContactsAsync(filter)
let contacts = listContacts(filter)
if (type === 'user' || type === 'group') {
contacts = contacts.filter((c) => c.type === type)
}
sendJson(res, 200, { count: contacts.length, contacts })
}),
},
'/api/v1/chatroom': withMethods(['GET'], async ({ res, url }) => {
'/api/v1/chatroom': ({ 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
// exactly the fields that stay un-hydrated on macOS.
let groups = (await listContactsAsync()).filter((c) => c.type === 'group')
let groups = listContacts().filter((c) => c.type === 'group')
if (keyword) {
const lower = keyword.toLowerCase()
groups = groups.filter(
@@ -345,16 +254,16 @@ const routes: Record<string, RouteHandler> = {
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
}),
},
'/api/v1/recent_chat': withMethods(['GET'], ({ res, url }) => {
'/api/v1/recent_chat': ({ 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': withMethods(['GET'], ({ res, url }) => {
'/api/v1/chatlog': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const talker = url.searchParams.get('talker')
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
@@ -392,27 +301,27 @@ const routes: Record<string, RouteHandler> = {
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
)
})
}),
},
'/api/v1/group_snapshot': withMethods(['GET'], ({ res, url }) => {
'/api/v1/group_snapshot': ({ 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': withMethods(['GET'], ({ res, url }) => {
'/api/v1/resolve': ({ 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': withMethods(['POST'], async ({ req, res, body }) => {
'/api/v1/report': 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()) {
@@ -439,9 +348,9 @@ const routes: Record<string, RouteHandler> = {
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
}),
},
'/api/v1/agent/group-report': withMethods(['POST'], async ({ req, res, body }) => {
'/api/v1/agent/group-report': 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' }
@@ -455,9 +364,9 @@ const routes: Record<string, RouteHandler> = {
range: request.range
})
sendJson(res, result.success ? 200 : 400, result)
}),
},
'/api/v1/agent/status': withMethods(['GET'], ({ res }) => {
'/api/v1/agent/status': ({ res }) => {
const status = agentHubService.getStatus()
sendJson(res, 200, {
ok: status.hub === 'online' && status.connector === 'online',
@@ -467,9 +376,9 @@ const routes: Record<string, RouteHandler> = {
databaseReady: status.databaseReady,
accountId: status.accountId
})
}),
},
'/api/v1/agent/send': withMethods(['POST'], async ({ req, res, body }) => {
'/api/v1/agent/send': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let request: { to?: string; text?: string; media_url?: string }
try {
@@ -483,7 +392,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'
@@ -533,18 +442,18 @@ function createScheduledReportRoute(
api: ScheduledReportApiService
): RouteHandler | undefined {
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
return withMethods(['GET'], async ({ req, res }) => {
return 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 withMethods(['GET', 'POST'], async ({ req, res, body }) => {
return async ({ req, res, body }) => {
try {
if (req.method === 'GET') {
const tasks = await api.list()
@@ -560,7 +469,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
})
}
}
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
@@ -573,7 +482,7 @@ function createScheduledReportRoute(
} catch {
return undefined
}
return withMethods(['POST'], async ({ req, res }) => {
return async ({ req, res }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
try {
const execution = await api.retrySend(executionId)
@@ -581,7 +490,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
})
}
}
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
@@ -595,15 +504,8 @@ function createScheduledReportRoute(
return undefined
}
const action = segments[1]
if (action && !['enable', 'disable', 'run', 'executions'].includes(action)) return undefined
const methods: HttpMethod[] = !action
? ['GET', 'PATCH', 'DELETE']
: action === 'executions'
? ['GET']
: ['POST']
return withMethods(methods, async ({ req, res, body }) => {
return async ({ req, res, body }) => {
try {
if (!action && req.method === 'GET') {
sendJson(res, 200, { task: await api.get(taskId) })
@@ -643,7 +545,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
})
}
}
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
@@ -658,46 +560,42 @@ function queryStatusCode(status: string): number {
return 400
}
function createQueryRoute(pathname: string, api: LocalQueryApiService): RouteHandler | undefined {
if (pathname === '/api/v1/query/capabilities') {
return withMethods(['GET'], ({ res }) => {
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 请求')
return sendJson(res, 200, api.capabilities())
})
}
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
}
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let payload: any
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 = await operation(payload)
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}`)
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 withMethods(['GET', 'HEAD'], async ({ req, res, url }) => {
return async ({ req, res, url }) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return sendError(res, 405, '需要 GET 请求')
}
const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length)
let messageId: string
try {
@@ -736,218 +634,7 @@ 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(
@@ -959,19 +646,11 @@ 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') {
@@ -980,37 +659,15 @@ export function startHttpServer(
}
const handler =
routes[url.pathname] ||
createAgentApiRoute(url.pathname, agentApi) ||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX)
? createQueryRoute(url.pathname, queryApi)
: undefined) ||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(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
@@ -1020,22 +677,8 @@ export function startHttpServer(
const ctx: RouteContext = { req, res, url, body }
await handler(ctx)
} catch (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)
safeError('[HttpServer] 请求处理失败:', error)
if (!res.headersSent) {
if (agentApiRequest) {
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
return
}
sendError(res, 500, error instanceof Error ? error.message : String(error))
}
}
+122 -436
View File
@@ -14,17 +14,13 @@ import {
Menu,
Tray,
dialog,
protocol,
screen
protocol
} from 'electron'
import { dirname, extname, join } from 'path'
import { existsSync, promises as fsPromises } from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import trayTemplateIcon from '../../resources/trayTemplate.png?asset'
import trayTemplateRetinaIcon from '../../resources/trayTemplate@2x.png?asset'
import { WechatDb } from './wechat-db'
import { createTrayImage } from './tray-icon'
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
@@ -76,7 +72,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, setLocalGroupStatsService, setLocalQueryApiService } from './http-server'
import { apiServer, 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'
@@ -116,28 +112,22 @@ import { agentHubService } from './services/agent-hub-service'
import { WechatConnectorService } from './services/wechat-ilink'
import { wechatSendGateway } from './services/wechat-send-gateway'
import { groupExitMonitorService } from './services/group-exit-monitor-service'
import {
filterSendableFriendContacts,
leaveNotificationContactDisplayName
} from './services/leave-notification-target'
import { MessageListenerService } from './services/message-listener-service'
import { automationRuleStore } from './services/automation-rule-store'
import { automationExecutionLogService } from './services/automation-execution-log-service'
import { initAutomationService, getAutomationService } from './services/automation-service'
import { GroupStatsService } from './services/group-stats-service'
import { wechatActionLogService } from './services/wechat-action-log-service'
import { toPersonalWechatSendResult, wechatActionGateway } from './services/wechat-action-gateway'
import { wechatActionGateway } from './services/wechat-action-gateway'
import { personalWechatSendService } from './services/personal-wechat-send-service'
import { macWechatRuntimeManager } from './services/mac-wechat-runtime-manager'
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
import { scheduledReportService } from './services/scheduled-report-service'
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
import { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service'
import type {
PersonalWechatGeneratedTtsVoiceRequest,
PersonalWechatSendRequest,
PersonalWechatSendResult,
PersonalWechatSenderStatus
PersonalWechatSendResult
} from '../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportUpdateInput
} from '../shared/scheduled-report'
import { isTruthyDebugFlag } from '../shared/debug-flags'
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
import type {
@@ -206,12 +196,6 @@ import type {
WechatShareServiceConfig
} from '../shared/wechat-share-card'
async function currentPersonalWechatSenderStatus(): Promise<PersonalWechatSenderStatus> {
return process.platform === 'darwin'
? macWechatRuntimeManager.buildSenderStatus()
: personalWechatSendService.getStatus()
}
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
// handler. Wrap console.* before any other module logs anything.
@@ -227,7 +211,6 @@ let voiceService: VoiceService | null = null
let voiceRecognition: VoiceRecognitionUseCase | null = null
let voiceBatchService: VoiceBatchService | null = null
let knowledgeSearchService: KnowledgeSearchService | null = null
let groupStatsService: GroupStatsService | null = null
let localQueryApiService: LocalQueryApiService | null = null
let aiSearchPipelineService: AiSearchPipelineService | null = null
let queryAgentService: QueryAgentService | null = null
@@ -239,6 +222,7 @@ const databaseKeyStore = new DatabaseKeyStore()
const imageKeyConfigService = new ImageKeyConfigService()
const aiProviderService = new AIProviderService()
const textToSpeechSettingsService = new TextToSpeechSettingsService()
const personalWechatRuntimeManager = new PersonalWechatRuntimeManager()
const keyServiceMac = new KeyServiceMac()
const keyServiceWin = new KeyServiceWin()
const wechatShareConfigStore = new WechatShareConfigStore()
@@ -373,18 +357,6 @@ function configureRecallProtection(
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
const packagedTrayTemplateIconPath = join(process.resourcesPath, 'resources', 'trayTemplate.png')
const packagedTrayTemplateRetinaIconPath = join(
process.resourcesPath,
'resources',
'trayTemplate@2x.png'
)
const trayTemplateIconPath = existsSync(packagedTrayTemplateIconPath)
? packagedTrayTemplateIconPath
: trayTemplateIcon
const trayTemplateRetinaIconPath = existsSync(packagedTrayTemplateRetinaIconPath)
? packagedTrayTemplateRetinaIconPath
: trayTemplateRetinaIcon
protocol.registerSchemesAsPrivileged([
{
@@ -520,29 +492,9 @@ async function createLocalMediaResponse(request: Request, filePath: string): Pro
function createWindow(): void {
// 创建浏览器窗口
/*
* 初始尺寸按当前屏幕工作区的 60% 计算,不再用固定 1400×800。
* 两个夹逼都是必要的:
* - 下限:小屏上 60% 会算出比原固定值更小的窗口(1920×1080 的工作区高度
* 只有 ~985,60% 才 591 高),反而比改动前更糟;高度下限取 900,
* 即「高度拉大一点」的诉求。
* - 上限:不能超过工作区本身,否则初始尺寸会把标题栏顶出屏幕。
*/
const { workAreaSize } = screen.getPrimaryDisplay()
const initialWidth = Math.min(
Math.max(Math.round(workAreaSize.width * 0.6), 1400),
workAreaSize.width
)
const initialHeight = Math.min(
Math.max(Math.round(workAreaSize.height * 0.6), 900),
workAreaSize.height
)
const mainWindow = new BrowserWindow({
width: initialWidth,
height: initialHeight,
minWidth: 960,
minHeight: 640,
center: true,
width: 1400,
height: 800,
show: false,
autoHideMenuBar: true,
icon: appIconPath,
@@ -802,10 +754,6 @@ app.whenReady().then(async () => {
})
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
// 群员统计复用同一个 Knowledge 实例:它只是「读派生库 + 读成员名单」的编排,
// 不持有自己的数据库,也不新建索引。
groupStatsService = new GroupStatsService(knowledgeSearchService)
setLocalGroupStatsService(groupStatsService)
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
localQueryApiService.setImageTextCoverageProvider(() =>
@@ -882,6 +830,11 @@ app.whenReady().then(async () => {
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
}
})
personalWechatRuntimeManager.setProgressListener((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wechat-personal:runtimeProgress', status)
}
})
protocol.handle('wxe-media', async (request) => {
const filePath = videoAssetService?.pathForUrl(request.url)
if (!filePath) return new Response('Not found', { status: 404 })
@@ -937,6 +890,8 @@ app.whenReady().then(async () => {
// 设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.tracememo.app')
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
// 开发环境中默认使用 F12 打开或关闭 DevTools
// 生产环境中忽略 CommandOrControl + R
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -1042,120 +997,20 @@ app.whenReady().then(async () => {
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
}
const wcdb4Client = nextWechatDb.getWcdb4Client()
/**
* 正式 MessageListener(Observation Mode 接入)。
*
* 本轮**只监听、回读、规范化、去重、统计**,不触发任何业务:
* 不匹配关键词、不判断 @我、不出日报、不回复、不调 AI / Agent、不发送。
* 下一层的 Trigger 由后续任务接入。
*/
const messageListener = new MessageListenerService(wcdb4Client)
/**
* Automation v1(@我生成日报)。
*
* `isListening` 要等下面 `startMonitor` 有结果才知道,所以先用闭包变量占位。
*/
let automationListening = false
initAutomationService(wcdb4Client, { isListening: () => automationListening })
/**
* 旧「定时日报任务」迁移所需的会话解析器。
*
* **必须在数据库就绪之后注入**:旧 `task.group` 可能是会话 md5、群名或 roomId
* 三种形态,只有 `resolveMd5` 能收敛成稳定会话 id。
* 注入前若渲染层已经读过一次规则(`automation:listRules`),迁移会**整体推迟**
* 而不落盘 —— 注入这一步会立刻补跑(见 `AutomationRuleStore.setLegacyConversationResolver`)。
*
* 这样设计的原因:拿不到解析器时如果把旧数据判成「无法无损映射」,
* 用户的定时日报会被**无辜停用**,而事实只是"数据库还没打开"。
*/
automationRuleStore.setLegacyConversationResolver((raw) => {
const key = String(raw || '').trim()
if (!key) return undefined
// 已经是稳定会话 id 就直接用,不查库。
if (key.endsWith('@chatroom')) return key
try {
const username = String(chat.resolveMd5(key)?.m_nsUsrName || '').trim()
return username || undefined
} catch {
return undefined
}
})
/**
* 退群通知的唯一通路:**退群监控只负责产生事件,自动化负责发送**。
*
* `handleGroupExit` 自己吞掉异常,这里再兜一层 `.catch` 是防御性的 ——
* 未捕获的 rejection 会污染监控循环,而退群事实早就已经记录成功了。
*/
groupExitMonitorService.setGroupExitHandler((event) => {
try {
return getAutomationService()
.handleGroupExit(event)
.catch((error) => {
console.warn(
`[Automation] handleGroupExit failed: ${
error instanceof Error ? error.message : String(error)
}`
)
})
} catch (error) {
console.warn(
`[Automation] 退群通知未初始化,已跳过本次事件: ${
error instanceof Error ? error.message : String(error)
}`
)
return undefined
}
})
messageListener.onMessage((message) => {
// 日志只允许出现「类型 / 群或私聊 / 是否自己发 / @ 数量」这类不可逆标识,
// 不含 wxid、昵称、群名、正文与 source。
console.log(
`[MessageListener] incoming messageType=${message.messageType}` +
` group=${message.isGroup} self=${message.isSelf}` +
` mentions=${message.mentionTargets.length}`
)
// Automation 自带 isSelf / cooldown / 同消息幂等三重闸,不会形成回复循环。
// 用 try 包住同步那一段:`getAutomationService()` 在未初始化时会抛,
// 而 `.catch()` 只能接住异步拒绝 —— 漏了这层就会把异常抛进 MessageListener 的投递循环。
try {
void getAutomationService()
.handleMessage(message)
.catch((error) => {
console.warn(
`[Automation] handleMessage failed: ${
error instanceof Error ? error.message : String(error)
}`
)
})
} catch (error) {
console.warn(
`[Automation] 未初始化,已跳过本次消息: ${
error instanceof Error ? error.message : String(error)
}`
)
}
})
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
voiceService = new VoiceService(wcdb4Client, resolvedRoot)
voiceRecognition?.connect(voiceService, resolvedRoot)
stickerService = new StickerService(wcdb4Client)
videoAssetService = new VideoAssetService(wcdb4Client)
const monitoring = await wcdb4Client.startMonitor((event) => {
const monitoring = await wcdb4Client.startMonitor((type, json) => {
wcdb4Client.invalidateSessionCache()
// 正式 MessageListener:只做 coalesce + 有界回读 + dedup + 投递,不触发业务。
// v2 事件带 sessionId ⇒ 回读不再依赖「最近活跃会话」。
messageListener.handleNativeChange(event)
groupExitMonitorService.notifyDatabaseChanged(event.raw)
recallArchiveMonitor?.handleDatabaseChange(event.raw)
groupExitMonitorService.notifyDatabaseChanged(json)
recallArchiveMonitor?.handleDatabaseChange(json)
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('wcdb-change', { type: event.type, json: event.raw })
}
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
automationListening = monitoring === true
void groupExitMonitorService.start(monitoring)
const recentSession = sessions[0]
if (recentSession?.username) {
@@ -1297,54 +1152,7 @@ app.whenReady().then(async () => {
)
// 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
// 项目规则:**所有发送都必须经过 WechatActionGateway**(审计 + 幂等 + 同一个 Send Log)。
// 手动发送在改造前从这里直连 `PersonalWechatSendService`,于是完全不留痕 ——
// 排查「消息到底发没发出去」时恰好缺的就是那份证据。
// 普通手动发送保持 triggerType=user;带 postfixText 的日报图片由 Gateway 编排成
// 两个 automation action,以复用同一条 3s 队列,并严格在图片 sent 后才发后置词。
if (request.type === 'text' || request.type === 'image') {
const to = String(request.to || '').trim()
const recipient = {
type:
request.isGroup || to.endsWith('@chatroom') ? ('group' as const) : ('contact' as const),
id: to
}
if (request.type === 'image' && request.postfixText !== undefined) {
const sequence = await wechatActionGateway.executeReportImageSequence({
recipient,
imagePath: String(request.filePath || ''),
postfixText: request.postfixText
})
const imageResult = toPersonalWechatSendResult(
sequence.image,
await currentPersonalWechatSenderStatus()
)
if (!imageResult.success || !sequence.postfix) return imageResult
return {
...imageResult,
postfixSent: sequence.postfix.status === 'sent',
...(sequence.postfix.status === 'sent'
? {}
: { postfixError: sequence.postfix.reason || '后置词发送失败' })
}
}
const action = await wechatActionGateway.execute({
origin: 'user_manual',
purpose: request.type === 'text' ? 'manual_text' : 'manual_image',
triggerType: 'user',
recipient,
content:
request.type === 'text'
? { type: 'text', text: String(request.text || '') }
: { type: 'image', path: String(request.filePath || '') }
})
// 返回契约保持 `PersonalWechatSendResult`,界面判读不用改。
return toPersonalWechatSendResult(action, await currentPersonalWechatSenderStatus())
}
// 语音仍走既有分支:它有自己的网关入口 `wechat-personal:sendGeneratedTtsVoice`,
// 且需要先解析当前账号 wxid 才能编码。
if (String(request.fromId || '').trim()) {
if (request.type !== 'voice' || String(request.fromId || '').trim()) {
return personalWechatSendService.send(request)
}
let fromId = ''
@@ -1661,153 +1469,31 @@ app.whenReady().then(async () => {
return snapshot
})
/**
* 群员统计(单群)。
*
* 时间单位刻意用 epoch **毫秒**:这一路完全走 Knowledge,而 Knowledge 内部口径就是毫秒。
* 沿用聊天消息的秒级口径会在 service 内部凭空多出一次换算 —— 而单位换错是**静默读 0 条**,
* 不会报错。
*/
ipcMain.handle(
'group-stats:getMemberStats',
async (_, request: { userMd5?: unknown; startTime?: unknown; endTime?: unknown }) => {
if (!groupStatsService) throw new Error('群员统计服务尚未就绪')
const userMd5 = String(request?.userMd5 || '').trim()
if (!userMd5) throw new Error('缺少会话标识')
const startTime = Number(request?.startTime)
const endTime = Number(request?.endTime)
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
throw new Error('统计时间范围无效')
}
return groupStatsService.getMemberStats({ userMd5, startTime, endTime })
}
)
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
groupExitMonitorService.setEnabled(enabled === true)
)
/**
* 保存**监控范围**。
*
* 只有一个参数:旧版第二个参数(通知群聊)已经迁到「自动化 → 退群通知」,
* 退群监控不再持有任何通知配置。
*/
ipcMain.handle('group-exit-monitor:setGroups', (_, roomIds: string[]) =>
groupExitMonitorService.setMonitoredRoomIds(Array.isArray(roomIds) ? roomIds : [])
ipcMain.handle(
'group-exit-monitor:setGroups',
(_, roomIds: string[], notificationRoomIds?: string[]) =>
groupExitMonitorService.setMonitoredRoomIds(
Array.isArray(roomIds) ? roomIds : [],
Array.isArray(notificationRoomIds) ? notificationRoomIds : []
)
)
ipcMain.handle('group-exit-monitor:setTemplate', (_, template: unknown) =>
groupExitMonitorService.setNotificationTemplate(template)
)
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
/**
* 按群查退群事件(档案合并展示用)。
*
* 时间参数是 epoch **毫秒**,与事件的 `detectedAt` 同口径。
*/
ipcMain.handle('group-exit-monitor:listEvents', (_, query: unknown) => {
const input = (query || {}) as {
roomId?: unknown
sinceMs?: unknown
untilMs?: unknown
limit?: unknown
}
return groupExitMonitorService.listEvents({
roomId: typeof input.roomId === 'string' ? input.roomId : undefined,
sinceMs: Number(input.sinceMs),
untilMs: Number(input.untilMs),
limit: Number(input.limit)
})
})
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
ipcMain.handle('group-exit-monitor:resendEvent', (_, eventId: string) =>
groupExitMonitorService.resendEvent(eventId)
)
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
groupExitMonitorService.markRead(readAt)
)
ipcMain.handle('wechat-action-log:list', () => wechatActionLogService.list())
/**
* Automation v1(@我生成日报)。
*
* 规则与执行日志都是纯文件存储,**不依赖数据库**,所以这两组 handler 在数据库
* 解锁前也可以安全调用;只有 `getStatus` / `listGroups` 需要会话数据,因此用
* `tryAutomationService()` 兜底,避免渲染层在启动阶段拿到一个 rejected promise。
*/
const tryAutomationService = (): ReturnType<typeof getAutomationService> | null => {
try {
return getAutomationService()
} catch {
return null
}
}
ipcMain.handle('automation:listRules', () => automationRuleStore.listRules())
ipcMain.handle('automation:createRule', (_, draft: unknown) =>
automationRuleStore.createRule(draft)
)
ipcMain.handle('automation:updateRule', (_, input: unknown) => {
const payload = (input || {}) as { id?: unknown; draft?: unknown }
return automationRuleStore.updateRule(String(payload.id || ''), payload.draft) ?? null
})
ipcMain.handle('automation:deleteRule', (_, id: string) => automationRuleStore.deleteRule(id))
ipcMain.handle('automation:setRuleEnabled', (_, input: unknown) => {
const payload = (input || {}) as { id?: unknown; enabled?: unknown }
return (
automationRuleStore.setRuleEnabled(String(payload.id || ''), payload.enabled === true) ?? null
)
})
ipcMain.handle('automation:listExecutions', (_, query: unknown) => {
const input = (query || {}) as { limit?: unknown }
return automationExecutionLogService.list({ limit: Number(input.limit) })
})
ipcMain.handle('automation:clearExecutions', () => automationExecutionLogService.clear())
ipcMain.handle('automation:listGroups', () => tryAutomationService()?.listGroups() ?? [])
/**
* 保存「退群通知」规则(singleton upsert)。
*
* 单独一个通道而不是复用 `updateRule`:这条规则是系统规则,
* 保存语义是"存在即更新、不存在即创建",且 id 固定 —— 不允许渲染层自己拼 id。
*/
ipcMain.handle('automation:saveLeaveNotificationRule', (_, draft: unknown) =>
automationRuleStore.saveLeaveNotificationRule(draft)
)
/**
* 「指定好友」的可选项。
*
* 过滤(群聊 / 公众号 / 文件传输助手 / 自己)在 main 侧完成 ——
* 只有这里知道当前登录账号是谁,渲染层再筛一遍必然会漂移。
*/
ipcMain.handle('automation:listSendableContacts', () => {
try {
const selfWxid = String(chat.getSelfAccountInfo()?.wxid || '')
return filterSendableFriendContacts(chat.listContacts(), selfWxid).map((contact) => ({
id: String(contact.m_nsUsrName || ''),
name: leaveNotificationContactDisplayName(contact)
}))
} catch (error) {
console.warn(
`[Automation] 读取可选联系人失败: ${error instanceof Error ? error.message : String(error)}`
)
return []
}
})
ipcMain.handle('automation:getStatus', async () => {
const service = tryAutomationService()
if (service) return service.getStatus()
// 数据库尚未就绪:如实返回「未监听 + 能力未知」,而不是假装一切正常。
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const counts = automationExecutionLogService.countSince(todayStart.getTime())
return {
listening: false,
listeningDegraded: true,
todayExecutions: counts.total,
todaySuccesses: counts.success,
sendCapability: {
supported: false,
ready: false,
canSendText: false,
canSendImage: false,
message: '数据库尚未就绪,暂时无法获知微信发送能力'
}
}
})
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
ipcMain.handle(
'knowledge:search',
@@ -2027,58 +1713,44 @@ app.whenReady().then(async () => {
})
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
/**
* 定时日报(Automation 的 `scheduled_report` 规则类型)。
*
* 规则本身的 CRUD 走上面的 `automation:*`;这里只补三块**专属**能力:
* 1. 「立即执行」—— 走 `AutomationService.executeScheduledRule(ruleId,'manual')`,
* 与 scheduler 触发**同一条链路**(生成 → 落库 → 解析目标 → Gateway 发图);
* 2. 微信异常通知 —— 定时日报失败时的 Agent Hub 推送,随功能保留;
* 3. 旧执行记录**只读存档** —— 旧记录无法无损转换,原样保留给 UI 展示。
*/
ipcMain.handle('automation:runScheduledReportRule', async (_, ruleId: string) => {
const service = tryAutomationService()
if (!service) return { success: false, error: '数据库尚未就绪,暂时无法执行定时日报' }
const outcome = await service.executeScheduledRule(String(ruleId || ''), { trigger: 'manual' })
if (outcome.executed) {
return { success: outcome.status !== 'failed', data: outcome }
}
// 「没执行」的每一种原因都要能翻译成用户看得懂的一句话 —— 否则点了按钮没反应,无从排查。
const skipMessages: Record<string, string> = {
missing_rule: '规则 id 无效',
rule_not_found: '未找到这条定时日报规则',
disabled: '这条定时日报已停用,请先启用再执行',
target_needs_review: '这条规则的发送目标需要重新选择后才能执行',
in_flight: '这条规则正在执行中,请稍后再试',
store_error: '读取规则失败,请稍后再试'
}
return {
success: false,
error: skipMessages[outcome.reason || ''] || '定时日报未执行',
data: outcome
}
})
ipcMain.handle('automation:listScheduledReportLegacyExecutions', (_, ruleId?: string) =>
scheduledReportService.listLegacyExecutions(ruleId)
ipcMain.handle('scheduled-report:list', () => scheduledReportService.listTasks())
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
scheduledReportService.listExecutions(taskId)
)
ipcMain.handle('automation:getScheduledReportNotificationSettings', () =>
ipcMain.handle('scheduled-report:getNotificationSettings', () =>
scheduledReportService.getNotificationSettings()
)
ipcMain.handle('automation:getScheduledReportNotificationCapability', () =>
scheduledReportService.checkNotificationCapability()
)
ipcMain.handle('automation:setScheduledReportNotificationEnabled', (_, enabled: boolean) =>
ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) =>
scheduledReportService.setNotificationEnabled(Boolean(enabled))
)
ipcMain.handle('automation:testScheduledReportErrorNotification', (_, ruleId: string) => {
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
scheduledReportService.createTask(request)
)
ipcMain.handle(
'scheduled-report:update',
(_, taskId: string, request: ScheduledReportUpdateInput) =>
scheduledReportService.updateTask(taskId, request)
)
ipcMain.handle('scheduled-report:delete', (_, taskId: string) =>
scheduledReportService.deleteTask(taskId)
)
ipcMain.handle('scheduled-report:setEnabled', (_, taskId: string, enabled: boolean) =>
scheduledReportService.setTaskEnabled(taskId, Boolean(enabled))
)
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
scheduledReportService.runScheduledReportNow(taskId)
)
ipcMain.handle('scheduled-report:retrySend', (_, executionId: string) =>
scheduledReportService.retryScheduledReportSend(executionId)
)
ipcMain.handle('scheduled-report:testErrorNotification', (_, taskId: string) => {
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
return Promise.resolve({
success: false,
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
})
}
return scheduledReportService.testScheduledReportErrorNotification(ruleId)
return scheduledReportService.testScheduledReportErrorNotification(taskId)
})
ipcMain.handle('report:reveal', async (_, filePath: string) => {
@@ -2544,13 +2216,11 @@ app.whenReady().then(async () => {
if (client) {
voiceService = new VoiceService(client, client.getAccountRoot())
voiceRecognition?.connect(voiceService, client.getAccountRoot())
const monitoring = await client.startMonitor((event) => {
const monitoring = await client.startMonitor((type, json) => {
client.invalidateSessionCache()
groupExitMonitorService.notifyDatabaseChanged(event.raw)
groupExitMonitorService.notifyDatabaseChanged(json)
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('wcdb-change', { type: event.type, json: event.raw })
}
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
void groupExitMonitorService.start(monitoring)
@@ -2675,16 +2345,42 @@ app.whenReady().then(async () => {
agentHubService.clearConversations()
return { success: true }
})
ipcMain.handle('wechat-personal:getStatus', () => currentPersonalWechatSenderStatus())
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
ipcMain.handle('wechat-personal:getKeepProcess', () =>
personalWechatSendService.getKeepOneBotProcess()
)
ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) =>
personalWechatSendService.setKeepOneBotProcess(Boolean(keep))
)
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
personalWechatSendService.checkWindowsStatus(port)
)
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
personalWechatVoiceEnvironmentService.check()
)
ipcMain.handle('wechat-personal:installPilk', () =>
personalWechatVoiceEnvironmentService.installPilk()
)
ipcMain.handle('wechat-personal:installPilk', async () => {
const result = await personalWechatVoiceEnvironmentService.installPilk()
if (!result.success || process.platform !== 'darwin') return result
const senderStatus = await personalWechatSendService.getStatus()
if (!senderStatus.oneBotPid) return result
try {
const restartedStatus = await personalWechatSendService.restartRuntime()
return {
...result,
restarted: restartedStatus.state !== 'error',
...(restartedStatus.state === 'error'
? { restartError: restartedStatus.error || restartedStatus.message }
: {})
}
} catch (error) {
return {
...result,
restarted: false,
restartError: error instanceof Error ? error.message : String(error)
}
}
})
ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => {
try {
await shell.openExternal('https://www.python.org/downloads/macos/')
@@ -2701,20 +2397,23 @@ app.whenReady().then(async () => {
return { success: false, error: '无法打开 FFmpeg 安装页面' }
}
})
ipcMain.handle('wechat-personal:rebind', async () => {
if (process.platform !== 'darwin') return personalWechatSendService.rebind()
const result = await macWechatRuntimeManager.bind()
const status = await macWechatRuntimeManager.buildSenderStatus()
if (result.ok) return status
return {
...status,
state: 'error',
message: result.message,
error: result.message
}
ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
success: personalWechatRuntimeManager.cancelDownload()
}))
ipcMain.handle('wechat-personal:removeRuntime', async () => {
await personalWechatSendService.terminate(true)
return personalWechatRuntimeManager.remove()
})
ipcMain.handle('wechat-personal:openRuntimeDirectory', async () => {
const status = await personalWechatRuntimeManager.getStatus()
const directory = status.directory || personalWechatRuntimeManager.directory
await fsPromises.mkdir(directory, { recursive: true })
const error = await shell.openPath(directory)
return error ? { success: false, error } : { success: true }
})
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
ipcMain.handle(
'wechat-personal:sendGeneratedTtsVoice',
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
@@ -2742,7 +2441,7 @@ app.whenReady().then(async () => {
action.sendResult && typeof action.sendResult === 'object'
? (action.sendResult as PersonalWechatSendResult)
: undefined
const status = sendResult?.status || (await currentPersonalWechatSenderStatus())
const status = sendResult?.status || (await personalWechatSendService.getStatus())
return { action, status }
}
)
@@ -2817,21 +2516,9 @@ app.on('before-quit', (event) => {
chat.closeChatDbForQuit().catch(() => false),
voiceRecognition?.dispose().catch(() => undefined),
knowledgeSearchService?.dispose().catch(() => undefined),
/*
* Deliberately do NOT stop the macOS native runtime here.
*
* The host is designed to outlive TraceMemo ("保留发送能力进程"): keeping
* it alive preserves the bound frida session, so relaunching the app —
* including every dev-mode restart — re-adopts it and works immediately
* instead of forcing the user to bind WeChat again.
*
* Stopping it on quit threw that away and, worse, gave teardown a chance
* to hang mid-unload while the app was already exiting. The host has its
* own lifecycle instead: it exits itself once WeChat is gone ("stale" ->
* self-shutdown), and it can be restarted explicitly from the settings
* card ("重新加载组件").
*/
Promise.resolve()
personalWechatSendService.terminate().catch((error) => {
console.warn('[Shutdown] personal WeChat sender cleanup failed:', error)
})
])
if (!nativeCallsDrained) {
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
@@ -2889,12 +2576,11 @@ function buildTrayMenu(): Menu {
function setupTray(): void {
if (tray) return
try {
const trayImage = createTrayImage(
process.platform,
appIconPath,
{ oneX: trayTemplateIconPath, twoX: trayTemplateRetinaIconPath },
nativeImage
)
const image = nativeImage.createFromPath(appIconPath)
const traySize = process.platform === 'darwin' ? 20 : 24
const trayImage = image.isEmpty()
? nativeImage.createEmpty()
: image.resize({ width: traySize, height: traySize, quality: 'best' })
tray = new Tray(trayImage)
tray.setToolTip('TraceMemo')
// macOS may show a Tray context menu on a primary click when it is set
@@ -4,7 +4,6 @@ import type {
KnowledgeImageOcrState,
KnowledgeAttachmentMetadata,
KnowledgeEvidence,
KnowledgeMemberStatsResult,
KnowledgeMessageKind,
KnowledgePassProgress,
KnowledgeRuntimeState,
@@ -499,37 +498,6 @@ export class KnowledgeSearchService {
}
}
/**
* 单群发言聚合(群员统计的数据来源)。
*
* 这一层只**如实**返回引擎能给出的东西(含 `indexLatestAt`),不做追赶决策 ——
* 「要不要等索引、要不要把结果标成不完整」是产品判断,属于 GroupStatsService。
* 同时返回一次 `sourceLatestAt`(同步、零额外 WCDB 调用),让调用方一次拿到
* freshness 的两个口径,不必再发一次 status 请求。
*/
async memberStats(request: {
conversationId: string
startTime: number
endTime: number
}): Promise<{ result: KnowledgeMemberStatsResult | null; sourceLatestAt: number | null }> {
const sourceLatestAt = this.sourceLatestAt()
const accountId = this.currentAccountId()
if (!accountId) return { result: null, sourceLatestAt }
try {
const result = await this.service.memberStats({
accountId,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
conversationId: request.conversationId,
startTime: request.startTime,
endTime: request.endTime
})
return { result, sourceLatestAt }
} catch (error) {
console.warn('[Knowledge] member stats failed:', error)
return { result: null, sourceLatestAt }
}
}
/**
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
*/
-9
View File
@@ -5,8 +5,6 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchResult,
@@ -55,13 +53,6 @@ export class KnowledgeService {
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
}
/** 单个会话内「按发送者聚合」的发言统计(群员统计用)。 */
memberStats(
request: Omit<KnowledgeMemberStatsRequest, 'databaseRoot'>
): Promise<KnowledgeMemberStatsResult> {
return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot })
}
/** 只中止正在跑的索引任务;查询请求不受影响。 */
cancelIndex(): Promise<boolean> {
return this.worker.cancelActiveIndex()
-88
View File
@@ -13,7 +13,6 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeNormalizedMessage,
KnowledgeQuery,
@@ -318,93 +317,6 @@ export class KnowledgeStore {
}
}
/**
* 单个会话在时间窗内的「按发送者聚合」统计。
*
* 只回聚合结果、不回消息正文:群员统计只需要「谁说了几条、最后一条是什么时候」,
* 把消息逐条搬到主进程再统计会把几十万行推过 IPC 边界。
*
* 三条硬规则**全部由 SQL 保证**,不指望调用方记得:
* 1. `kind <> 'system'`:系统消息不是任何成员的发言(微信侧 10000/10002 在建库时
* 已归一为 `kind = 'system'`,见 `knowledge-search-service` 的 kind 映射);
* 2. `sender_id IS NULL` 的行不归给任何人,只计入 `unattributedMessages` ——
* 硬塞给某个成员会让「未发言」名单出现错误否定;
* 3. 时间窗口是**闭区间**,单位 **epoch 毫秒**,与 knowledge 内部口径一致,
* 不经过 WCDB 的秒级边界(跨错单位会静默读到 0 条)。
*/
memberStats(request: {
conversationId: string
startTime: number
endTime: number
}): KnowledgeMemberStatsResult {
const { conversationId, startTime, endTime } = request
const indexLatestAt = this.readIndexLatestAt()
const empty: KnowledgeMemberStatsResult = {
conversationId,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: null,
indexLatestAt
}
if (!conversationId) return empty
// 用 `(conversation_id, create_time)` 索引直接命中:这是本查询唯一的访问路径,
// 写成全表扫描等价于把单群统计的 26ms 变成 10s。
const scope = 'conversation_id = ? AND create_time >= ? AND create_time <= ?'
const args = [conversationId, startTime, endTime]
const count = (extra: string): number => {
const row = this.database
.prepare(`SELECT COUNT(*) AS n FROM knowledge_messages WHERE ${scope} AND ${extra}`)
.get(...args) as DbRow | undefined
return Number(row?.n) || 0
}
const rows = asRows(
this.database
.prepare(
`SELECT sender_id,
COUNT(*) AS message_count,
MAX(create_time) AS last_message_time
FROM knowledge_messages
WHERE ${scope} AND kind <> 'system' AND sender_id IS NOT NULL
GROUP BY sender_id
ORDER BY message_count DESC, last_message_time DESC`
)
.all(...args)
)
const senders: KnowledgeMemberStatsResult['senders'] = []
for (const row of rows) {
const senderId = String(row.sender_id ?? '').trim()
if (!senderId) continue
senders.push({
senderId,
messageCount: Number(row.message_count) || 0,
lastMessageTime: Number(row.last_message_time) || 0
})
}
// 窗口内最早一条消息:**不过滤 kind** —— 群的第一条常常是建群通知,
// 那才是用户认知里的「这个群第一条消息」。选「全部」时用它显示真实起点。
const earliestRow = this.database
.prepare(`SELECT MIN(create_time) AS m FROM knowledge_messages WHERE ${scope}`)
.get(...args) as DbRow | undefined
const earliestValue = Number(earliestRow?.m)
return {
conversationId,
totalMessages: count("kind <> 'system'"),
senders,
unattributedMessages: count("kind <> 'system' AND sender_id IS NULL"),
excludedSystemMessages: count("kind = 'system'"),
earliestMessageTime: Number.isFinite(earliestValue) && earliestValue > 0 ? earliestValue : null,
indexLatestAt
}
}
/**
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
*
@@ -6,8 +6,6 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchResult,
@@ -21,7 +19,6 @@ type WorkerResult =
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| KnowledgeMemberStatsResult
| { marks: Record<string, number> }
| { removed: true }
type PendingRequest = {
@@ -79,11 +76,6 @@ export class KnowledgeWorkerHost {
)
}
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
}
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
async cancelActiveIndex(): Promise<boolean> {
const target = this.activeIndexRequestId
-38
View File
@@ -1,8 +1,6 @@
import type {
KnowledgeCapacityPreflightRequest,
KnowledgeIndexRequest,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeStatusRequest,
@@ -189,38 +187,6 @@ async function handleHighWater(
})
}
/**
* 群员统计的按发送者聚合。
*
* 与 `handleSearch` 同样先判库是否存在:**「还没建索引」是正常状态,不是故障**,
* 返回空结果而不是抛错,让上层能稳定地区分「没人发言」与「索引不存在」。
*/
async function handleMemberStats(
request: KnowledgeWorkerRequest,
payload: KnowledgeMemberStatsRequest
): Promise<void> {
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
if (!existsSync(path)) {
const unavailable: KnowledgeMemberStatsResult = {
conversationId: payload.conversationId,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: null,
indexLatestAt: null
}
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return
}
send({
version: 1,
type: 'result',
requestId: request.requestId,
payload: getStore(payload).memberStats(payload)
})
}
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
try {
if (request.type === 'cancel') {
@@ -260,10 +226,6 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'memberStats') {
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
return
}
if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest)
return
@@ -1,731 +0,0 @@
import {
AUTOMATION_STEP_LABELS,
AUTOMATION_SEND_ORIGIN,
AUTOMATION_SEND_PURPOSE,
DEFAULT_REPLY_TEXT,
automationIdempotencyKey,
normalizeReplyDelaySeconds,
scheduledReportRangeLabel,
type AutomationAction,
type AutomationExecutionTrigger,
type AutomationRule,
type AutomationStep,
type AutomationStepKey,
type LeaveNotificationConfig,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import {
renderLeaveNotificationText,
type GroupMemberExitedEvent
} from '../../shared/group-exit-event'
import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
import type { SaveGeneratedReportRequest, SaveGeneratedReportResult } from '../../shared/report-history'
import {
generateAgentGroupReport,
type AgentGroupReportRequest,
type AgentGroupReportResult
} from './agent-group-report-service'
import { wechatActionGateway } from './wechat-action-gateway'
import { saveGeneratedReport } from '../report-history-service'
import { getContactAvatars, resolveMd5 } from './chat-service'
import type { AutomationTargetResolution } from './automation-wechat-target'
/**
* AutomationActionRunner —— 把一次命中跑成**步骤序列**。
*
* 两条硬要求:
*
* 1. **每一步都有独立的状态、起止时间与耗时。** 用户层日志的全部价值就在于此:
* 只告诉用户「失败了」等于没说,得告诉他是「生成日报」那步坏了。
* 2. **失败必须切断后续。** 日报没生成出来就绝不能继续发图 —— 那会发出一条
* 空的 / 过期的 / 上一条的消息,比不发更糟。所以失败后剩下的步骤一律 `skipped`。
*/
/** 一步执行完毕后,后续步骤的处置方式。 */
const STEP_ORDER: AutomationStepKey[] = ['received', 'matched', 'reply', 'report', 'send']
/** 退群通知的步骤顺序(与消息型规则**不共用**)。 */
const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
'exit_received',
'exit_matched',
'exit_target',
'exit_send'
]
/**
* 定时日报的步骤顺序(与另外两族**不共用**)。
*
* 「生成中 / 已生成」与「确定目标 / 已发送」**刻意拆成四步**:
* 用户必须能一眼看出"日报确实生成了,只是没发出去" —— 合成两步就表达不了。
*/
const SCHEDULED_REPORT_STEP_ORDER: AutomationStepKey[] = [
'schedule_triggered',
'report_generating',
'report_generated',
'send_resolved',
'report_sent'
]
/**
* 前置步骤失败时,后续步骤的 `skipReason`。
*
* 必须写清「是因为前面那步没成」,否则用户看到一连串「已跳过」会以为是规则没配好。
*/
const SKIPPED_AFTER_REPLY_FAILURE = '前置步骤失败(回复确认未成功),本次不再继续。'
const SKIPPED_AFTER_REPORT_FAILURE = '前置步骤失败(日报未生成),没有图片可发送。'
/**
* 定时日报「生成 / 落库」失败时,后续步骤的 `skipReason`。
*
* 与 `SKIPPED_AFTER_REPORT_FAILURE` 的区别:那条说的是「@我日报没图可发」,
* 这条说的是「定时日报这一步就没跑起来」—— 文案分开,避免用户混淆两条链路。
*/
const SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE =
'前置步骤失败(定时日报未生成或未保存),本次未发送。'
/**
* 规则启用了「发送日报图片」,但手上没有图片文件。
*
* 这不是「正常跳过」,也不允许退而求其次去发空路径 / 上一张旧图 / 不存在的文件 ——
* 发错东西比不发更糟,所以直接判失败。
*/
const SEND_WITHOUT_IMAGE_ERROR = '日报图片未生成,无法发送。'
export interface AutomationRunInput {
executionId: string
rule: AutomationRule
/** 会话标识:群为 `xxx@chatroom`,私聊为 wxid。同时也是发送对象。 */
conversationId: string
isGroup: boolean
/** 用户可读的来源名(群名 / 昵称)。**不是** id。 */
sourceDisplayName: string
}
export interface AutomationRunResult {
steps: AutomationStep[]
status: 'success' | 'failed'
errorSummary?: string
pngPath?: string
}
/**
* 退群通知的执行输入。
*
* 目标解析与发送能力预检都由 `AutomationService` 在调用前完成,
* 这里只负责"把它跑成步骤序列" —— 于是**所有步骤构造只在一处**,
* 不会出现"服务拼一半、runner 拼一半"的裂口。
*/
export interface LeaveNotificationRunInput {
executionId: string
event: GroupMemberExitedEvent
config: LeaveNotificationConfig
resolution: AutomationTargetResolution
/** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */
sourceDisplayName: string
/** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */
sendBlockedReason?: string
}
export interface LeaveNotificationRunResult {
steps: AutomationStep[]
status: 'success' | 'failed'
errorSummary?: string
}
/**
* 定时日报的执行输入。
*
* 与退群通知同构:目标解析、来源显示名、发送能力预检都在 `AutomationService`
* 调用前算好,runner 只负责"跑成步骤序列"。
*/
export interface ScheduledReportRunInput {
executionId: string
rule: AutomationRule
config: ScheduledReportAutomationConfig
resolution: AutomationTargetResolution
/** 日报来源群的显示名(群名 / 「日报来源群」)。**不含 roomId**。 */
sourceDisplayName: string
/**
* 本次触发方式。
*
* 只影响**发送节流口径**:定时触发算 `automation`(纳入发送节流),
* 用户在页面上点「立即执行」算 `user`(与旧定时日报的手动执行一致)。
* 执行日志里的 `trigger` 由 `AutomationService` 单独记录,不从这里读。
*/
trigger: Extract<AutomationExecutionTrigger, 'schedule' | 'manual'>
/** 发送能力缺失时的一句话说明;有值时 `report_sent` 直接判失败。 */
sendBlockedReason?: string
}
export interface ScheduledReportRunResult {
steps: AutomationStep[]
status: 'success' | 'failed'
errorSummary?: string
/**
* 日报是否**已经生成并落库**。
*
* 这是「生成成功但发送失败」的判据:`reportGenerated === true && status === 'failed'`
* 就是"日报在,只是没发出去"。日报本身已进日报历史,不会被丢掉。
*/
reportGenerated: boolean
/** 生成出来的 PNG(仅在内存里传递,**不落执行日志**)。 */
pngPath?: string
/**
* 底层生成错误的**机器可读码**(例如 `NO_MESSAGES`)。
*
* 用途只有一个:让上层区分「真的失败了」和「这一天没有消息可生成」——
* 后者不该给用户推微信异常通知。
*/
errorCode?: string
}
export interface AutomationActionRunnerDependencies {
generateReport?: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
now?: () => number
/** 延迟实现。默认真 sleep;单测注入即时 resolve 的假实现,避免真的等 2 秒。 */
delay?: (ms: number) => Promise<void>
/** 日报落库(日报历史)。与旧定时日报**复用同一个**实现,不复制。 */
saveGeneratedReport?: (request: SaveGeneratedReportRequest) => Promise<SaveGeneratedReportResult>
/** 把日报来源群标识解析成联系人(拿头像 / 显示名)。 */
resolveReportContact?: (raw: string) => {
md5?: string
m_nsUsrName?: string
m_nsNickName?: string
avatar?: string
} | null
getContactAvatars?: (usernames: string[]) => Promise<Record<string, string>>
}
/** 策略层的错误码 → 用户可读短句。UI 直接展示这些文案,不做二次翻译。 */
const ACTION_ERROR_MESSAGES: Record<string, string> = {
INVALID_REQUEST: '发送请求不合法',
INVALID_RECIPIENT: '找不到有效的发送对象',
ACTION_NOT_ALLOWED: '该自动化动作未被允许执行',
RECIPIENT_SCOPE_VIOLATION: '发送对象与触发来源不一致',
SEND_CAPABILITY_UNAVAILABLE: '当前环境没有可用的微信发送能力',
SEND_NOT_READY: '微信发送能力尚未就绪,请先绑定个人微信',
SEND_FAILED: '微信发送失败',
POLICY_BLOCKED: '该发送动作未通过策略检查',
UNKNOWN: '发送失败(未知原因)'
}
function createStep(key: AutomationStepKey): AutomationStep {
return { key, label: AUTOMATION_STEP_LABELS[key], status: 'pending' }
}
function markSuccess(step: AutomationStep, at: number): void {
step.status = 'success'
step.startedAt = step.startedAt ?? at
step.finishedAt = at
step.durationMs = Math.max(0, at - step.startedAt)
}
function markFailed(step: AutomationStep, at: number, error: string): void {
step.status = 'failed'
step.startedAt = step.startedAt ?? at
step.finishedAt = at
step.durationMs = Math.max(0, at - step.startedAt)
step.error = error
}
function markSkipped(step: AutomationStep, skipReason?: string): void {
step.status = 'skipped'
if (skipReason) step.skipReason = skipReason
}
function actionErrorMessage(code: string | undefined, fallback: string | undefined): string {
if (fallback && fallback.trim()) {
// 策略层给的 reason 已是中文短句;直接用,避免二次包装丢信息。
return fallback.trim()
}
return (code && ACTION_ERROR_MESSAGES[code]) || ACTION_ERROR_MESSAGES.UNKNOWN
}
export class AutomationActionRunner {
private readonly generateReport: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
private readonly executeAction: (request: WechatActionRequest) => Promise<WechatActionResult>
private readonly now: () => number
private readonly delay: (ms: number) => Promise<void>
private readonly saveGeneratedReport: (
request: SaveGeneratedReportRequest
) => Promise<SaveGeneratedReportResult>
private readonly resolveReportContact: NonNullable<
AutomationActionRunnerDependencies['resolveReportContact']
>
private readonly getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
constructor(dependencies: AutomationActionRunnerDependencies = {}) {
this.generateReport = dependencies.generateReport ?? generateAgentGroupReport
this.executeAction = dependencies.executeAction ?? ((request) => wechatActionGateway.execute(request))
this.now = dependencies.now ?? (() => Date.now())
this.delay =
dependencies.delay ??
((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
this.saveGeneratedReport = dependencies.saveGeneratedReport ?? saveGeneratedReport
this.resolveReportContact = dependencies.resolveReportContact ?? ((raw) => resolveMd5(raw))
this.getContactAvatars = dependencies.getContactAvatars ?? ((ids) => getContactAvatars(ids))
}
async run(input: AutomationRunInput): Promise<AutomationRunResult> {
const steps = STEP_ORDER.map((key) => createStep(key))
const stepAt = (key: AutomationStepKey): AutomationStep =>
steps.find((step) => step.key === key) as AutomationStep
markSuccess(stepAt('received'), this.now())
markSuccess(stepAt('matched'), this.now())
const replyAction = findAction(input.rule, 'replyText')
const reportAction = findAction(input.rule, 'generateReport')
const sendAction = findAction(input.rule, 'sendReportImage')
// ---- 步骤 3:回复确认 ----
//
// 回复等待:规则一命中就秒回,看起来就是个机器人(消息刚到、回复就到)。
// 等待时长是**规则自己的一项执行参数**(`rule.replyDelaySeconds`,在
// 「编辑自动化 → 3 · 触发后执行」里配),所以不同规则可以不一样。
//
// 等待刻意放在 `reply` 步骤计时**之外** —— `reply.durationMs` 只应该反映发送本身,
// 否则用户看到「回复确认 2000ms」会误以为是发送慢。
// 已经命中就不再回头重判规则:等待窗口里规则被停用/删掉也不中断本次执行,
// 与 cooldown、同消息幂等的口径一致(都是「命中那一刻」的快照)。
if (replyAction) {
// 用共享的归一化函数,而不是 `Number(...) || 0`:旧版 rules.json 里没有这个字段,
// 那应该按**默认 2 秒**处理(否则「默认 2 秒」要等用户手动进编辑页才会生效)。
const replyDelayMs = normalizeReplyDelaySeconds(input.rule.replyDelaySeconds) * 1_000
if (replyDelayMs > 0) await this.delay(replyDelayMs)
}
if (!replyAction) {
markSkipped(stepAt('reply'))
} else {
const replyStep = stepAt('reply')
replyStep.status = 'running'
replyStep.startedAt = this.now()
const sent = await this.sendThroughGateway(input, 'reply', {
type: 'text',
text: replyAction.text?.trim() || DEFAULT_REPLY_TEXT
})
if (!sent.ok) {
markFailed(replyStep, this.now(), sent.error || '回复确认失败')
markRemainingSkipped(steps, 'reply', SKIPPED_AFTER_REPLY_FAILURE)
return { steps, status: 'failed', errorSummary: replyStep.error }
}
markSuccess(replyStep, this.now())
}
// ---- 步骤 4:生成日报 ----
let pngPath: string | undefined
if (!reportAction) {
markSkipped(stepAt('report'))
} else {
const reportStep = stepAt('report')
reportStep.status = 'running'
reportStep.startedAt = this.now()
let result: AgentGroupReportResult
try {
result = await this.generateReport({ group: input.conversationId, range: 'today' })
} catch (error) {
result = {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
if (!result.success || !result.pngPath) {
markFailed(reportStep, this.now(), result.error || '日报生成失败')
markRemainingSkipped(steps, 'report', SKIPPED_AFTER_REPORT_FAILURE)
return { steps, status: 'failed', errorSummary: reportStep.error }
}
pngPath = result.pngPath
markSuccess(reportStep, this.now())
}
// ---- 步骤 5:发送日报图片 ----
//
// 三种情况必须分开判断 —— 合并成 `!sendAction || !pngPath` 会把
// 「规则要求发图、但图根本没生成」当成正常跳过,execution 还记成 success:
//
// A. 规则**本来就没有启用**这个动作 → skipped,这是正常的,不影响整体结果;
// B. 启用了,但要发的东西不存在 → **failed**,不能假装成功,
// 更不能退而求其次去发空路径 / 上一次的旧图 / 不存在的文件;
// C. 前置(生成日报)已经失败 → 上面就 return 了,走不到这里。
if (!sendAction) {
markSkipped(stepAt('send'))
return { steps, status: 'success', ...(pngPath ? { pngPath } : {}) }
}
const sendStep = stepAt('send')
if (!pngPath) {
markFailed(sendStep, this.now(), SEND_WITHOUT_IMAGE_ERROR)
return { steps, status: 'failed', errorSummary: sendStep.error }
}
sendStep.status = 'running'
sendStep.startedAt = this.now()
const sent = await this.sendThroughGateway(input, 'report', { type: 'image', path: pngPath })
if (!sent.ok) {
markFailed(sendStep, this.now(), sent.error || '发送日报图片失败')
return { steps, status: 'failed', errorSummary: sendStep.error, pngPath }
}
markSuccess(sendStep, this.now())
return { steps, status: 'success', pngPath }
}
/**
* 定时日报:把一次「到点触发 / 手动立即执行」跑成一次生成 + 一次发送。
*
* 与旧 `ScheduledReportService.executeTask` 的行为对齐(**不重写日报能力**):
* 1. 先生成(含落库进日报历史)—— 发送能力不足**也照常生成**(旧语义如此);
* 2. 再解析目标 —— 解析失败**不 fallback**,直接判失败;
* 3. 最后经 `WechatActionGateway` 发图片 —— 仍然是**发图片**,不退化成纯文本。
*
* 「生成成功但发送失败」是可表达的:`report_generated` 为 success、
* `report_sent` 为 failed,整体 `failed`,且 `reportGenerated === true`。
*/
async runScheduledReport(input: ScheduledReportRunInput): Promise<ScheduledReportRunResult> {
const steps = SCHEDULED_REPORT_STEP_ORDER.map((key) => createStep(key))
const stepAt = (key: AutomationStepKey): AutomationStep =>
steps.find((step) => step.key === key) as AutomationStep
markSuccess(stepAt('schedule_triggered'), this.now())
// ---- 生成日报(含落进日报历史)----
const generatingStep = stepAt('report_generating')
generatingStep.status = 'running'
generatingStep.startedAt = this.now()
let generated: AgentGroupReportResult
try {
generated = await this.generateReport({
group: input.config.report.sourceConversationId,
range: input.config.report.range,
messageTypes: input.config.report.messageTypes,
templateId: input.config.report.templateId,
memberNameMode: input.config.report.memberNameMode,
timeoutSeconds: input.config.report.timeoutSeconds
})
} catch (error) {
generated = {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
if (!generated.success || !generated.pngPath) {
// 生成失败就**不发**:绝不生成空图片、也绝不退而求其次发上一张旧图。
const reason = generated.error || '日报生成失败'
markFailed(generatingStep, this.now(), reason)
markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
return {
steps,
status: 'failed',
errorSummary: reason,
reportGenerated: false,
...(generated.errorCode ? { errorCode: generated.errorCode } : {})
}
}
let pngPath = generated.pngPath
try {
const reportContact = this.resolveReportContact(input.config.report.sourceConversationId)
let contactAvatar = reportContact?.avatar
if (!contactAvatar && reportContact?.m_nsUsrName) {
try {
const avatars = await this.getContactAvatars([reportContact.m_nsUsrName])
contactAvatar = avatars[reportContact.m_nsUsrName]
} catch (error) {
console.warn('[Automation] 日报群头像补全失败:', error)
}
}
const savedHistory = await this.saveGeneratedReport({
contactId: reportContact?.md5 || input.config.report.sourceConversationId,
contactName:
generated.groupName || reportContact?.m_nsNickName || input.sourceDisplayName || '群聊',
contactAvatar,
source: 'scheduled',
dateRange: generated.reportMetadata?.dateRange || scheduledReportRangeLabel(input.config.report.range),
reportDate: generated.reportMetadata?.reportDate,
messageCount: generated.messageCount ?? generated.reportMetadata?.messageCount ?? 0,
generatedAt: new Date(this.now()).toISOString(),
htmlPath: generated.htmlPath,
pngPath: generated.pngPath,
duration: generated.duration,
modelName: generated.modelName,
tokenUsage: generated.tokenUsage,
reportSnapshot: generated.reportSnapshot,
reportMetadata: generated.reportMetadata,
templateId: input.config.report.templateId
})
if (!savedHistory.success) {
throw new Error(savedHistory.error || '日报历史保存失败')
}
const recordPath = savedHistory.record?.pngPath || generated.pngPath
if (!recordPath) throw new Error('日报历史未返回可发送的 PNG 文件')
pngPath = recordPath
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
markFailed(generatingStep, this.now(), reason)
markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
return { steps, status: 'failed', errorSummary: reason, reportGenerated: false }
}
markSuccess(generatingStep, this.now())
const generatedStep = stepAt('report_generated')
markSuccess(generatedStep, this.now())
const messageCount = generated.messageCount ?? 0
if (messageCount > 0) generatedStep.detail = `共 ${messageCount} 条消息`
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
const targetStep = stepAt('send_resolved')
if (!input.resolution.ok) {
markFailed(targetStep, this.now(), input.resolution.error)
markSkipped(stepAt('report_sent'), '没有可用的发送目标,本次未发送。')
return {
steps,
status: 'failed',
errorSummary: input.resolution.error,
reportGenerated: true,
pngPath
}
}
targetStep.status = 'success'
targetStep.startedAt = this.now()
targetStep.finishedAt = targetStep.startedAt
targetStep.durationMs = 0
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
targetStep.detail = input.resolution.target.displayName
const sendStep = stepAt('report_sent')
// ---- 发送能力缺失:日报已生成,如实判失败(不回滚、不隐藏)----
if (input.sendBlockedReason) {
markFailed(sendStep, this.now(), input.sendBlockedReason)
return {
steps,
status: 'failed',
errorSummary: input.sendBlockedReason,
reportGenerated: true,
pngPath
}
}
sendStep.status = 'running'
sendStep.startedAt = this.now()
const sent = await this.sendScheduledReportImage(input, pngPath)
if (!sent.ok) {
markFailed(sendStep, this.now(), sent.error || '发送日报失败')
return {
steps,
status: 'failed',
errorSummary: sendStep.error,
reportGenerated: true,
pngPath
}
}
markSuccess(sendStep, this.now())
return { steps, status: 'success', reportGenerated: true, pngPath }
}
/**
* 定时日报的统一发送出口。
*
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里。
* Automation 层不允许知道 OneBot / WCHook / native host / Windows hook 的存在。
*/
private async sendScheduledReportImage(
input: ScheduledReportRunInput,
pngPath: string
): Promise<{ ok: boolean; error?: string }> {
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
// 定时触发走 automation(纳入发送节流);用户手动执行走 user(不纳入节流)。
const triggerType = input.trigger === 'manual' ? 'user' : 'automation'
try {
const result = await this.executeAction({
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReport}:${input.executionId}`,
origin: AUTOMATION_SEND_ORIGIN,
purpose: AUTOMATION_SEND_PURPOSE.scheduledReport,
triggerType,
executionId: input.executionId,
recipient: input.resolution.target.recipient,
content: { type: 'image', path: pngPath }
})
if (result.status !== 'sent') {
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
}
/*
* 后置词:**只在图片明确 sent 之后**才发,图片失败时严格短路 ——
* 与 `WechatActionGateway.executeReportImageSequence`(手动发送)同一口径。
* 空字符串 = 用户只要图片,什么都不补发。
*
* 独立 purpose + 独立幂等位:共用一个 key 会让「图片发成功、后置词被
* 幂等短路」变成常态。
*/
const postfixText = String(input.config.postfixText || '').trim()
if (!postfixText) return { ok: true }
const postfix = await this.executeAction({
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReportPostfix}:${input.executionId}`,
origin: AUTOMATION_SEND_ORIGIN,
purpose: AUTOMATION_SEND_PURPOSE.scheduledReportPostfix,
triggerType,
executionId: input.executionId,
recipient: input.resolution.target.recipient,
content: { type: 'text', text: postfixText }
})
if (postfix.status === 'sent') return { ok: true }
// 绝不吞掉:图片确实发出去了,但这一次执行**没有完成**,必须如实报出来。
return {
ok: false,
error: `日报图片已发送,但后置词发送失败:${actionErrorMessage(
postfix.errorCode,
postfix.reason
)}`
}
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
/**
* 退群通知:把「检测到成员退出」这件事跑成一次发送。
*
* 与 `run()` 的差别:没有动作链、没有回复等待、没有 cooldown
* (退群是低频事件,且**不能被时间窗合并** —— 两个成员先后退出就是两条通知)。
*/
async runLeaveNotification(input: LeaveNotificationRunInput): Promise<LeaveNotificationRunResult> {
const steps = LEAVE_NOTIFICATION_STEP_ORDER.map((key) => createStep(key))
const stepAt = (key: AutomationStepKey): AutomationStep =>
steps.find((step) => step.key === key) as AutomationStep
markSuccess(stepAt('exit_received'), this.now())
markSuccess(stepAt('exit_matched'), this.now())
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
if (!input.resolution.ok) {
markFailed(stepAt('exit_target'), this.now(), input.resolution.error)
markSkipped(stepAt('exit_send'), '没有可用的通知目标,未发送。')
return { steps, status: 'failed', errorSummary: input.resolution.error }
}
const targetStep = stepAt('exit_target')
targetStep.status = 'success'
targetStep.startedAt = this.now()
targetStep.finishedAt = targetStep.startedAt
targetStep.durationMs = 0
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
targetStep.detail = input.resolution.target.displayName
const sendStep = stepAt('exit_send')
// ---- 发送能力缺失:如实判失败(退群事实本身仍然是成功的) ----
if (input.sendBlockedReason) {
markFailed(sendStep, this.now(), input.sendBlockedReason)
return { steps, status: 'failed', errorSummary: input.sendBlockedReason }
}
const text = renderLeaveNotificationText(input.event, input.config.template).trim()
if (!text) {
const error = '通知内容为空,未发送。'
markFailed(sendStep, this.now(), error)
return { steps, status: 'failed', errorSummary: error }
}
sendStep.status = 'running'
sendStep.startedAt = this.now()
const sent = await this.sendLeaveNotification(input, text)
if (!sent.ok) {
markFailed(sendStep, this.now(), sent.error || '发送退群通知失败')
return { steps, status: 'failed', errorSummary: sendStep.error }
}
markSuccess(sendStep, this.now())
return { steps, status: 'success' }
}
/**
* 退群通知的统一发送出口。
*
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里,
* 直接调个人微信发送会绕过全部三样。Automation 层也不允许知道
* OneBot / WCHook / native host / Windows hook 的存在。
*
* 幂等键由 **eventId** 派生(不是 executionId):审计是落盘的,
* 于是"重启后重复投递同一退群事件"会被持久层直接短路。
*/
private async sendLeaveNotification(
input: LeaveNotificationRunInput,
text: string
): Promise<{ ok: boolean; error?: string }> {
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
try {
const result = await this.executeAction({
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${input.event.eventId}`,
origin: AUTOMATION_SEND_ORIGIN,
purpose: AUTOMATION_SEND_PURPOSE.leaveNotification,
triggerType: 'automation',
executionId: input.executionId,
sourceId: input.event.eventId,
recipient: input.resolution.target.recipient,
content: { type: 'text', text }
})
if (result.status === 'sent') return { ok: true }
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
/**
* 统一发送出口。
*
* **刻意不直接调 `WechatSendGateway`,而是走 `WechatActionGateway`**:后者在
* `WechatSendGateway` 之上多给了三样本功能必须的东西 ——
* 幂等(同一 executionId 不会重复发)、自动化发送节流(3s 间隔,防刷屏)、
* 审计落盘。底层实际发送仍然经由 `WechatSendGateway.sendPersonal`,
* 所以「所有发送统一走 WechatSendGateway」这条约束依然成立。
*/
private async sendThroughGateway(
input: AutomationRunInput,
kind: 'reply' | 'report',
content: WechatActionContent
): Promise<{ ok: boolean; error?: string }> {
try {
const result = await this.executeAction({
idempotencyKey: automationIdempotencyKey(kind, input.executionId),
origin: AUTOMATION_SEND_ORIGIN,
purpose: AUTOMATION_SEND_PURPOSE[kind],
triggerType: 'automation',
executionId: input.executionId,
recipient: {
type: input.isGroup ? 'group' : 'contact',
id: input.conversationId,
name: input.sourceDisplayName
},
content
})
if (result.status === 'sent') return { ok: true }
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
} catch (error) {
// execute() 本身刻意不抛,这里兜的是注入实现或意外异常。
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
function findAction(rule: AutomationRule, type: AutomationAction['type']): AutomationAction | undefined {
return rule.actions.find((action) => action.type === type && action.enabled)
}
/** 把 `after` 之后的步骤全部标成 `skipped`(前一步挂了,后面的不许再动)。 */
function markRemainingSkipped(
steps: AutomationStep[],
after: AutomationStepKey,
skipReason?: string
): void {
const from = STEP_ORDER.indexOf(after) + 1
for (const key of STEP_ORDER.slice(from)) {
const step = steps.find((item) => item.key === key)
if (step) markSkipped(step, skipReason)
}
}
export const automationActionRunner = new AutomationActionRunner()
@@ -1,153 +0,0 @@
import path from 'node:path'
import { app } from 'electron'
import fs from 'fs-extra'
import type {
AutomationExecution,
AutomationExecutionTrigger
} from '../../shared/automation'
/**
* AutomationExecutionLogService —— **用户层**执行日志。
*
* 与 debug 日志的区别:这份记录是给用户看「哪一步坏了」的,所以
* - 只保留用户可读字段(规则名、来源显示名、步骤状态、耗时、错误);
* - **不含** wxid / localId / serverId / source XML / raw payload / 图片绝对路径;
* - 必须落盘,重启后还能看到近期记录。
*
* 容量有界(`MAX_RECORDS`),否则长期运行会把文件撑到几十兆。
*/
const STORAGE_DIR = 'automation'
const EXECUTIONS_FILE = 'executions.json'
const MAX_RECORDS = 200
export interface AutomationExecutionLogDependencies {
userDataPath?: () => string
}
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
const EXECUTION_TRIGGERS: AutomationExecutionTrigger[] = ['message', 'exit', 'schedule', 'manual']
function normalizeExecution(value: unknown): AutomationExecution | null {
if (!value || typeof value !== 'object') return null
const record = value as Partial<AutomationExecution>
const executionId = String(record.executionId || '').trim()
if (!executionId) return null
return {
executionId,
ruleId: String(record.ruleId || ''),
ruleName: String(record.ruleName || ''),
triggerTime: Number(record.triggerTime) || 0,
/*
* `trigger` 必须在这里**显式透传**。
*
* 这个归一化是白名单式的:没列出来的字段会被静默丢掉。定时日报的
* 「本次是定时跑的还是用户点的立即执行」全靠这个字段区分,
* 漏掉它就会变成"写的时候有、读出来永远没有"—— 而且没有任何报错。
* 旧记录没有这个字段,保持 `undefined`(读盘不猜测)。
*/
...(EXECUTION_TRIGGERS.includes(record.trigger as AutomationExecutionTrigger)
? { trigger: record.trigger as AutomationExecutionTrigger }
: {}),
sourceDisplayName: String(record.sourceDisplayName || ''),
// 不认识的 status(含历史遗留值)一律降级成 `running`,绝不凭空造出成功/失败。
status: EXECUTION_STATUSES.includes(record.status as AutomationExecution['status'])
? (record.status as AutomationExecution['status'])
: 'running',
durationMs: Number(record.durationMs) || 0,
steps: Array.isArray(record.steps) ? record.steps : [],
...(record.errorSummary ? { errorSummary: String(record.errorSummary) } : {})
}
}
export class AutomationExecutionLogService {
private readonly userDataPath: () => string
private records: AutomationExecution[] = []
private loaded = false
constructor(dependencies: AutomationExecutionLogDependencies = {}) {
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
}
list(query: { limit?: number } = {}): AutomationExecution[] {
this.ensureLoaded()
const requested = Number(query?.limit)
const limit = Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : MAX_RECORDS
return this.records.slice(0, Math.min(limit, MAX_RECORDS)).map((record) => structuredClone(record))
}
/**
* 写入(或按 `executionId` 覆盖)一条执行记录。
*
* 覆盖语义是必需的:执行是「先建 running、跑完再回落终态」,
* 中间态与终态共用同一个 `executionId`。
*/
record(execution: AutomationExecution): void {
this.ensureLoaded()
const normalized = normalizeExecution(execution)
if (!normalized) return
const withoutSame = this.records.filter((item) => item.executionId !== normalized.executionId)
this.records = [normalized, ...withoutSame].slice(0, MAX_RECORDS)
this.persist()
}
clear(): boolean {
this.ensureLoaded()
this.records = []
this.persist()
return true
}
/**
* 统计 `sinceMs` 之后(含)的记录数与成功数。用于顶部「今日执行」。
*
* 每条记录都对应一次**真正跑过**的执行(gate 拦下的消息不会产生记录),
* 所以这里直接计数即可。
*/
countSince(sinceMs: number): { total: number; success: number } {
this.ensureLoaded()
const from = Number(sinceMs) || 0
let total = 0
let success = 0
for (const record of this.records) {
if (record.triggerTime < from) continue
total += 1
if (record.status === 'success') success += 1
}
return { total, success }
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const raw = fs.readJsonSync(this.executionsFilePath()) as unknown
const values = Array.isArray(raw) ? raw : []
this.records = values
.map((value) => normalizeExecution(value))
.filter((value): value is AutomationExecution => value !== null)
.slice(0, MAX_RECORDS)
} catch {
this.records = []
}
}
private executionsFilePath(): string {
return path.join(this.userDataPath(), STORAGE_DIR, EXECUTIONS_FILE)
}
private persist(): void {
try {
const filePath = this.executionsFilePath()
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(filePath, this.records, { spaces: 2 })
} catch (error) {
console.warn(
`[Automation] 保存执行日志失败: ${error instanceof Error ? error.message : String(error)}`
)
}
}
}
export const automationExecutionLogService = new AutomationExecutionLogService()
-676
View File
@@ -1,676 +0,0 @@
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { app } from 'electron'
import fs from 'fs-extra'
import {
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule,
createDefaultScheduledReportRule,
normalizeRuleDraft,
type AutomationRule,
type AutomationRuleType,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import {
planLeaveNotificationMigration,
type LeaveNotificationMigrationPlan,
type LegacyLeaveNotificationState
} from './leave-notification-migration'
import {
planScheduledReportMigrationBatch,
type LegacyScheduledReportTask,
type ScheduledReportMigrationSummary
} from './scheduled-report-migration'
import {
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
insertGroupNamePlaceholder
} from '../../shared/group-exit-monitor'
/**
* AutomationRuleStore —— 自动化规则的持久化与增删改查。
*
* 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。
* 规则总量是个位数到几十条,引入数据库只会增加迁移负担。
*
* **一次性标记全部单独存**(`builtinSeeded` / `leaveNotificationMigrated` /
* `notificationTemplateUpgraded` / `scheduledReportMigrated`):
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。迁移同理。
*/
const STORAGE_DIR = 'automation'
const RULES_FILE = 'rules.json'
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
const LEGACY_MONITOR_FILE = 'group-exit-monitor.json'
/** 旧定时日报的**任务**文件(迁移时读一次,之后运行期不再读)。 */
const LEGACY_SCHEDULED_DIR = 'scheduled-reports'
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**。 */
builtinSeeded: boolean
/** 旧退群通知配置是否已经迁移过。**即使随后被删除也保持 true**。 */
leaveNotificationMigrated: boolean
/**
* 是否已经替用户往模板里补过 `{groupName}`。
*
* **必须有这个标记**:没有它就得靠"模板里有没有 `{groupName}`"来判断,
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
*/
notificationTemplateUpgraded: boolean
/**
* 旧「定时日报」任务是否已经迁移过。
*
* ⚠️ 与其它标记有一点不同:迁移需要**把旧群标识解析成稳定会话 id**,
* 而那需要数据库。数据库没就绪时这一位**保持 false**,等解析器注入后再补跑 ——
* 否则会把每一条规则都误判成"目标无法确认"。
*/
scheduledReportMigrated: boolean
rules: AutomationRule[]
}
export interface AutomationRuleStoreDependencies {
userDataPath?: () => string
now?: () => number
/**
* 迁移旧定时日报时,把 legacy 群标识(会话 md5 / 群名 / roomId)解析成
* **稳定会话 id**(`xxx@chatroom`)。解析不到返回 `undefined`。
*
* 不注入 ⇒ 迁移**推迟**(不写任何规则、不置标记)。
*/
resolveLegacyConversationId?: (raw: string) => string | undefined
}
function emptyState(): StoredRules {
return {
version: CURRENT_VERSION,
builtinSeeded: false,
leaveNotificationMigrated: false,
notificationTemplateUpgraded: false,
scheduledReportMigrated: false,
rules: []
}
}
function normalizeRuleType(value: unknown): AutomationRuleType {
return value === 'leave_notification'
? 'leave_notification'
: value === 'scheduled_report'
? 'scheduled_report'
: 'daily_report'
}
/**
* 单条规则的读盘归一化。
*
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / 各类 config。
*/
function normalizeStoredRule(value: unknown): AutomationRule | null {
if (!value || typeof value !== 'object') return null
const raw = value as Partial<AutomationRule>
const id = String(raw.id || '').trim()
if (!id) return null
const ruleType = normalizeRuleType(raw.ruleType)
const normalized = normalizeRuleDraft(
{
...raw,
ruleType,
// 历史规则没有这些字段;反过来也要避免脏字段落盘。
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {}),
...(ruleType === 'scheduled_report' ? { scheduledReport: raw.scheduledReport } : {})
},
String(raw.name || '').trim() || '未命名自动化'
)
const createdAt = Number(raw.createdAt) || 0
const updatedAt = Number(raw.updatedAt) || createdAt
return {
...normalized,
id,
createdAt,
updatedAt,
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
? { leaveNotification: normalized.leaveNotification }
: {}),
...(normalized.ruleType === 'scheduled_report' && normalized.scheduledReport
? { scheduledReport: normalized.scheduledReport }
: {})
}
}
/**
* 一次性替用户把 `{groupName}` 补进退群通知模板。
*
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
*
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
*/
function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
rules: AutomationRule[]
count: number
} {
let count = 0
const next = rules.map((rule) => {
if (rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return rule
const current = rule.leaveNotification.template
const upgraded = insertGroupNamePlaceholder(current)
// 补完不能超长:超了下次读盘会被校验拦下、整条模板被重置成默认,反而更糟。
if (upgraded === current || upgraded.length > GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH) {
return rule
}
count += 1
return { ...rule, leaveNotification: { ...rule.leaveNotification, template: upgraded } }
})
return { rules: next, count }
}
/**
* 调度游标(`lastRunAt` / `lastScheduledSlot`)**由 scheduler 拥有**,草稿改不动它们。
*
* 保存规则时一律从 `current` 取,而不是从草稿取 —— 否则用户进一次编辑页保存,
* 就会把"已消费的槽位"抹掉,导致当天重复补跑一次日报。
*/
function pickScheduledReportRuntime(
current: ScheduledReportAutomationConfig | undefined
): Partial<ScheduledReportAutomationConfig> {
if (!current) return {}
return {
...(current.lastRunAt ? { lastRunAt: current.lastRunAt } : {}),
...(current.lastScheduledSlot ? { lastScheduledSlot: current.lastScheduledSlot } : {})
}
}
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */
function normalizeStored(value: unknown): StoredRules {
if (!value || typeof value !== 'object') return emptyState()
const input = value as Partial<StoredRules>
const rules = Array.isArray(input.rules)
? input.rules
.map((rule) => normalizeStoredRule(rule))
.filter((rule): rule is AutomationRule => rule !== null)
: []
return {
version: Number(input.version) || 1,
builtinSeeded: input.builtinSeeded === true,
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
scheduledReportMigrated: input.scheduledReportMigrated === true,
rules
}
}
export class AutomationRuleStore {
private readonly userDataPath: () => string
private readonly now: () => number
private state: StoredRules = emptyState()
private loaded = false
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
private lastScheduledMigrationSummary: ScheduledReportMigrationSummary | null = null
private legacyConversationResolver: ((raw: string) => string | undefined) | null
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
this.now = dependencies.now ?? (() => Date.now())
this.legacyConversationResolver = dependencies.resolveLegacyConversationId ?? null
}
/**
* 注入 / 替换旧定时日报迁移所需的会话解析器(数据库就绪后由 main 调用)。
*
* 若之前因为"解析器不可用"推迟了迁移,这里会**立刻补跑**并落盘。
*/
setLegacyConversationResolver(resolver: (raw: string) => string | undefined): void {
this.legacyConversationResolver = resolver
if (!this.loaded) return
if (this.state.scheduledReportMigrated) return
const next = structuredClone(this.state)
if (this.runScheduledReportMigration(next)) this.commitState(next)
}
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
hasPendingScheduledReportMigration(): boolean {
this.ensureLoaded()
return !this.state.scheduledReportMigrated && this.readLegacyScheduledTasks().length > 0
}
listRules(): AutomationRule[] {
this.ensureLoaded()
return this.state.rules.map((rule) => structuredClone(rule))
}
getRule(id: string): AutomationRule | undefined {
const key = String(id || '').trim()
if (!key) return undefined
const found = this.listRules().find((rule) => rule.id === key)
return found
}
/** 上一次迁移的判定结果(未迁移时为 null)。 */
getLastLeaveNotificationMigration(): LeaveNotificationMigrationPlan | null {
this.ensureLoaded()
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
}
/** 上一次旧定时日报迁移的统计(未迁移时为 null)。 */
getLastScheduledReportMigration(): ScheduledReportMigrationSummary | null {
this.ensureLoaded()
return this.lastScheduledMigrationSummary ? { ...this.lastScheduledMigrationSummary } : null
}
createRule(draft: unknown): AutomationRule {
this.ensureLoaded()
const timestamp = this.now()
const normalized = normalizeRuleDraft(draft)
const rule: AutomationRule = {
...normalized,
id: randomUUID(),
createdAt: timestamp,
updatedAt: timestamp
}
this.commitRules([...this.state.rules, rule])
return structuredClone(rule)
}
updateRule(id: string, draft: unknown): AutomationRule | undefined {
this.ensureLoaded()
const key = String(id || '').trim()
const index = this.state.rules.findIndex((rule) => rule.id === key)
if (index < 0) return undefined
const current = this.state.rules[index]
// 名字留空时沿用原名,而不是变成「未命名自动化」—— 编辑页只改开关时不该改名。
const normalized = normalizeRuleDraft(draft, current.name)
const rule: AutomationRule = {
...current,
...normalized,
id: current.id,
// ruleType 不允许被草稿改掉:它是规则的**身份**,不是可编辑字段。
ruleType: current.ruleType,
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
? { leaveNotification: normalized.leaveNotification }
: {}),
...(current.ruleType === 'scheduled_report' && normalized.scheduledReport
? {
scheduledReport: {
...normalized.scheduledReport,
...pickScheduledReportRuntime(current.scheduledReport)
}
}
: {}),
createdAt: current.createdAt,
updatedAt: this.now()
}
// 切类型时不能留下另一种类型才认识的字段。
if (rule.ruleType === 'daily_report') {
delete rule.leaveNotification
delete rule.scheduledReport
} else if (rule.ruleType === 'leave_notification') {
delete rule.scheduledReport
} else {
delete rule.leaveNotification
}
this.commitRules(this.state.rules.map((item, at) => (at === index ? rule : item)))
return structuredClone(rule)
}
/**
* 保存「退群通知」规则(**singleton upsert**)。
*
* 不存在则创建(固定 id),存在则更新 —— 所以点多少次保存都只有一条规则。
*/
saveLeaveNotificationRule(draft: unknown): AutomationRule {
this.ensureLoaded()
const existing = this.state.rules.find(
(rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID
)
if (existing) {
const updated = this.updateRule(existing.id, { ...(draft as object), ruleType: 'leave_notification' })
if (updated) return updated
}
const timestamp = this.now()
const normalized = normalizeRuleDraft({ ...(draft as object), ruleType: 'leave_notification' })
const rule: AutomationRule = {
...createDefaultLeaveNotificationRule(timestamp),
...normalized,
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
ruleType: 'leave_notification',
name: normalized.name || '退群通知',
createdAt: timestamp,
updatedAt: timestamp
}
this.commitRules([...this.state.rules, rule])
return structuredClone(rule)
}
deleteRule(id: string): boolean {
this.ensureLoaded()
const key = String(id || '').trim()
const before = this.state.rules.length
const rules = this.state.rules.filter((rule) => rule.id !== key)
if (rules.length === before) return false
this.commitRules(rules)
return true
}
setRuleEnabled(id: string, enabled: boolean): AutomationRule | undefined {
const rule = this.getRule(id)
if (!rule) return undefined
return this.updateRule(id, { ...rule, enabled: enabled === true })
}
/**
* 写**调度游标**(`lastRunAt` / `lastScheduledSlot`)。
*
* 刻意不走 `updateRule`:这不是用户配置编辑,走草稿归一化会顺带触碰别的字段,
* 也可能被"草稿里没这个字段"给抹掉。这里只允许改这两个键。
*/
setScheduledReportRuntime(
id: string,
patch: { lastRunAt?: string; lastScheduledSlot?: string }
): AutomationRule | undefined {
this.ensureLoaded()
const key = String(id || '').trim()
const index = this.state.rules.findIndex((rule) => rule.id === key)
if (index < 0) return undefined
const current = this.state.rules[index]
if (current.ruleType !== 'scheduled_report' || !current.scheduledReport) return undefined
const next: AutomationRule = {
...current,
scheduledReport: {
...current.scheduledReport,
...(patch.lastRunAt !== undefined ? { lastRunAt: patch.lastRunAt } : {}),
...(patch.lastScheduledSlot !== undefined
? { lastScheduledSlot: patch.lastScheduledSlot }
: {})
},
updatedAt: this.now()
}
this.commitRules(this.state.rules.map((item, at) => (at === index ? next : item)))
return structuredClone(next)
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
let stored: StoredRules
try {
stored = normalizeStored(fs.readJsonSync(this.rulesFilePath()) as unknown)
} catch {
stored = emptyState()
}
if (!stored.builtinSeeded) {
stored.builtinSeeded = true
stored.rules = [...stored.rules, createDefaultDailyReportRule(this.now())]
}
if (!stored.leaveNotificationMigrated) {
stored.leaveNotificationMigrated = true
stored.rules = this.migrateLeaveNotification(stored.rules)
}
if (!stored.notificationTemplateUpgraded) {
stored.notificationTemplateUpgraded = true
const upgraded = upgradeLeaveNotificationTemplate(stored.rules)
stored.rules = upgraded.rules
// 只记条数,**不记模板正文**。
if (upgraded.count > 0) {
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
}
}
this.runScheduledReportMigration(stored)
stored.version = CURRENT_VERSION
this.state = stored
try {
this.persist()
} catch {
// Keep the in-memory defaults available; later writes report persistence failures.
}
}
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
private migrateLeaveNotification(rules: AutomationRule[]): AutomationRule[] {
if (rules.some((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)) return rules
const timestamp = this.now()
const legacy = this.readLegacyLeaveNotificationState()
if (!legacy) {
// 全新安装:没有历史可迁,直接建默认规则(默认目标:文件传输助手)。
this.logMigration('fresh_install', null)
return [...rules, createDefaultLeaveNotificationRule(timestamp)]
}
const plan = planLeaveNotificationMigration(legacy)
this.lastMigrationPlan = plan
this.writeMigrationBackup(legacy, plan)
this.logMigration(plan.outcome, plan)
return [
...rules,
createDefaultLeaveNotificationRule(timestamp, {
template: plan.template,
target: plan.target,
enabled: plan.enabled,
// 二次勾选(旧「通知群聊」)逐字保留,迁移不再是有损的。
notifyScope: plan.notifyScope,
notifyRoomIds: plan.notifyRoomIds
})
]
}
/**
* 旧「定时日报」任务 → `scheduled_report` 规则。**只跑一次**。
*
* 返回 `true` 表示本次真的做了迁移并需要落盘。
* **解析器不可用时返回 `false` 且不置标记** —— 迁移推迟到数据库就绪后补跑。
*/
private runScheduledReportMigration(stored: StoredRules): boolean {
if (stored.scheduledReportMigrated) return false
const tasks = this.readLegacyScheduledTasks()
if (!tasks.length) {
// 全新安装 / 从没用过旧定时日报:没有历史可迁,直接置位。
stored.scheduledReportMigrated = true
console.log('[Automation] 定时日报迁移 outcome=fresh_install')
return true
}
const resolver = this.legacyConversationResolver
if (!resolver) {
console.log(
`[Automation] 定时日报迁移已推迟:数据库尚未就绪,无法解析旧群标识(待迁 ${tasks.length} 条)`
)
return false
}
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolver, this.now())
this.lastScheduledMigrationSummary = summary
this.writeScheduledMigrationBackup(tasks, summary)
const existing = new Set(stored.rules.map((rule) => rule.id))
const migrated: AutomationRule[] = []
for (const plan of plans) {
// 幂等:同一 id 已存在就不再插入(重复执行迁移也不会多出规则)。
if (existing.has(plan.ruleId)) continue
existing.add(plan.ruleId)
migrated.push({
...createDefaultScheduledReportRule(plan.createdAt, {
id: plan.ruleId,
name: plan.name,
enabled: plan.enabled,
config: plan.config
}),
createdAt: plan.createdAt,
updatedAt: plan.updatedAt
})
}
stored.rules = [...stored.rules, ...migrated]
stored.scheduledReportMigrated = true
// 只记数量与判定分布,**禁止**出现群名 / roomId / 群主昵称。
console.log(
`[Automation] 定时日报迁移 outcome=done total=${summary.total} migrated=${migrated.length}` +
` lossless=${summary.lossless} needsReview=${summary.needsReview}` +
` duplicatesSkipped=${summary.duplicatesSkipped}`
)
return true
}
/**
* 读旧定时日报**任务**文件。
*
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
* 读失败视为"没有历史任务",不抛。
*/
private readLegacyScheduledTasks(): LegacyScheduledReportTask[] {
try {
const value = fs.readJsonSync(
path.join(this.userDataPath(), LEGACY_SCHEDULED_DIR, LEGACY_SCHEDULED_TASKS_FILE)
) as unknown
return Array.isArray(value) ? (value as LegacyScheduledReportTask[]) : []
} catch {
return []
}
}
/** 迁移前把旧任务**原样**落一份 backup(不做不可逆覆盖)。 */
private writeScheduledMigrationBackup(
tasks: LegacyScheduledReportTask[],
summary: ScheduledReportMigrationSummary
): void {
try {
const filePath = path.join(
this.userDataPath(),
STORAGE_DIR,
SCHEDULED_MIGRATION_BACKUP_FILE
)
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(
filePath,
{
migratedAt: this.now(),
summary,
// 备份里保留原始字段:这是**用户数据**,不是日志,不进任何用户可见界面。
legacyTasks: tasks
},
{ spaces: 2 }
)
} catch (error) {
console.warn(`[Automation] 写入定时日报迁移备份失败: ${errorText(error)}`)
}
}
/**
* 读旧退群监控状态文件里的通知相关字段。
*
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
* 读失败视为"没有历史配置",不抛。
*/
private readLegacyLeaveNotificationState(): LegacyLeaveNotificationState | null {
try {
const raw = fs.readJsonSync(
path.join(this.userDataPath(), LEGACY_MONITOR_FILE)
) as Partial<LegacyLeaveNotificationState>
if (!raw || typeof raw !== 'object') return null
return {
monitoredRoomIds: Array.isArray(raw.monitoredRoomIds) ? raw.monitoredRoomIds : [],
notificationRoomIds: Array.isArray(raw.notificationRoomIds) ? raw.notificationRoomIds : [],
...(raw.notificationTemplate !== undefined
? { notificationTemplate: raw.notificationTemplate }
: {})
}
} catch {
return null
}
}
/**
* 迁移前把旧配置原样落一份 backup。
*
* 不做不可逆覆盖:旧状态文件本身也**不删**,只是不再被运行时代码读取。
*/
private writeMigrationBackup(
legacy: LegacyLeaveNotificationState,
plan: LeaveNotificationMigrationPlan
): void {
try {
const filePath = path.join(this.userDataPath(), STORAGE_DIR, MIGRATION_BACKUP_FILE)
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(
filePath,
{
migratedAt: this.now(),
outcome: plan.outcome,
// 备份里保留原始 roomId:这是**用户数据**,不是日志,不进任何用户可见界面。
legacy: {
monitoredRoomIds: legacy.monitoredRoomIds,
notificationRoomIds: legacy.notificationRoomIds,
notificationTemplate: legacy.notificationTemplate
},
applied: {
enabled: plan.enabled,
targetType: plan.target.type,
notifyScope: plan.notifyScope,
notifyRoomIds: plan.notifyRoomIds
}
},
{ spaces: 2 }
)
} catch (error) {
console.warn(`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`)
}
}
/** 迁移日志:只允许出现判定结果与布尔量,**禁止** roomId / 模板正文。 */
private logMigration(outcome: string, plan: LeaveNotificationMigrationPlan | null): void {
if (!plan) {
console.log(`[Automation] 退群通知迁移 outcome=${outcome}`)
return
}
console.log(
`[Automation] 退群通知迁移 outcome=${outcome} enabled=${plan.enabled}` +
` targetType=${plan.target.type} needsReview=${plan.targetNeedsReview}`
)
}
private rulesFilePath(): string {
return path.join(this.userDataPath(), STORAGE_DIR, RULES_FILE)
}
private persist(): void {
try {
const filePath = this.rulesFilePath()
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(filePath, this.state, { spaces: 2 })
} catch (error) {
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
}
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export const automationRuleStore = new AutomationRuleStore()
File diff suppressed because it is too large Load Diff
@@ -1,266 +0,0 @@
import {
LEAVE_NOTIFICATION_TARGET_OPTIONS,
SCHEDULED_REPORT_TARGET_OPTIONS,
leaveNotificationTargetLabel,
scheduledReportTargetLabel
} from '../../shared/automation'
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
/**
* **中性的微信发送目标解析**。
*
* 退群通知与定时日报的目标语义完全一致(来源会话 / 自己 / 文件传输助手 / 指定好友),
* 差的只是"来源会话"从哪来:
* - 退群通知 → 事件所在群(`event.conversationId`);
* - 定时日报 → 该规则配置的日报来源群(`config.report.sourceConversationId`)。
*
* ⇒ 所以**只允许有一份实现**(只保留一套 resolve)。
* 两个业务各自只有一层薄适配(把参数摊平后交给这里),不重复任何判定逻辑。
*
* 五条规则(两边共用):
* 1. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手 / 来源群);
* 2. 只允许出现在选项表里的目标类型;
* 3. 显示名不带 wxid / roomId:日志与界面只出现用户能看懂的称呼;
* 4. 联系人必须**可发送**(排除群聊 / 公众号 / 文件传输助手 / 自己);
* 5. 只有 `source_chat` 用得到来源会话,其余目标完全不看它。
*/
/** 解析所需的最小联系人结构(main 侧 `FormattedContact` 结构上可赋值给它)。 */
export interface AutomationTargetContact {
m_nsUsrName: string
m_nsNickName?: string
md5?: string
type: 'user' | 'group'
isOfficialAccount?: boolean
wechatNickname?: string
remark?: string
}
/** 目标类型(两个业务共用同一组字面量)。 */
export type AutomationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
export interface ResolvedAutomationTarget {
recipient: { type: 'group' | 'contact'; id: string; name: string }
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
displayName: string
}
export type AutomationTargetResolution =
| { ok: true; target: ResolvedAutomationTarget }
| { ok: false; error: string }
/** 联系人的展示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
export function automationContactDisplayName(contact: AutomationTargetContact): string {
return (
contact.remark?.trim() ||
contact.wechatNickname?.trim() ||
contact.m_nsNickName?.trim() ||
'指定好友'
)
}
/**
* 这个联系人能不能作为「指定好友」的发送目标。
*
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
* 否则会出现"能选但发不出去"。
*/
export function isSendableFriendContact(
contact: AutomationTargetContact,
selfWxid?: string
): boolean {
if (contact.type !== 'user') return false
if (contact.isOfficialAccount) return false
const username = String(contact.m_nsUsrName || '').trim()
if (!username) return false
if (isFileHelperUsername(username)) return false
const self = String(selfWxid || '').trim()
if (self && username === self) return false
return true
}
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
contacts: T[],
selfWxid?: string
): T[] {
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
}
/** 目标类型是否在(该业务的)选项表里。 */
function isKnownTargetType(
type: string,
options: ReadonlyArray<{ type: string }>
): boolean {
return options.some((option) => option.type === type)
}
/**
* 把群标识解析成**稳定会话 id**(`xxx@chatroom`)。
*
* 兼容三种历史形态:roomId 本身 / 会话 md5 / 群名。
* 解析不出来返回 `undefined` —— 调用方必须如实报错,**不许回落**。
*/
export function resolveGroupConversationId(
raw: string,
contacts: AutomationTargetContact[]
): string | undefined {
const value = String(raw || '').trim()
if (!value) return undefined
if (value.endsWith('@chatroom')) return value
const matched = contacts.find(
(contact) =>
(contact.type === 'group' || contact.m_nsUsrName.endsWith('@chatroom')) &&
(contact.md5 === value ||
contact.m_nsUsrName === value ||
contact.m_nsNickName?.trim() === value)
)
const conversationId = String(matched?.m_nsUsrName || '').trim()
return conversationId.endsWith('@chatroom') ? conversationId : undefined
}
/** 群标识 → 可读群名(解析不到时返回空串,由调用方决定兜底文案)。 */
export function resolveGroupDisplayName(
raw: string,
contacts: AutomationTargetContact[]
): string {
const value = String(raw || '').trim()
if (!value) return ''
const matched = contacts.find(
(contact) =>
contact.m_nsUsrName === value ||
contact.md5 === value ||
contact.m_nsNickName?.trim() === value
)
return String(matched?.m_nsNickName || '').trim()
}
export interface ResolveAutomationTargetInput {
targetType: AutomationTargetType
/**
* `source_chat` 时使用:来源会话(退群事件所在群 / 日报来源群)。
* 允许是 roomId / 会话 md5 / 群名 —— 一律经 `resolveGroupConversationId` 收敛。
*/
sourceConversationId?: string
/** 来源会话的可读名(拿不到时留空,由这里给兜底称呼)。 */
sourceDisplayName?: string
/** `contact` 时使用:稳定 id(wxid / username)。 */
contactId?: string
contacts: AutomationTargetContact[]
/** 当前登录账号的 wxid;拿不到时传空串。 */
selfWxid?: string
}
/**
* 目标解析的**文案与类型表**(按业务注入)。
*
* 为什么把文案参数化而不是各写一份 switch:两个业务的**判定逻辑必须一模一样**,
* 只有"主语"不同。文案集中放在这里,判定仍然只有一份。
*/
export interface AutomationTargetMessages {
/** 目标类型表的单一来源(与 UI 选项同源)。 */
options: ReadonlyArray<{ type: string }>
/** 类型非法(伪造值 / 未知值)。 */
invalidTarget: string
/** `source_chat` 但来源会话拿不到。 */
sourceMissing: string
/** `source_chat` 且来源会话没有可读名时,`recipient.name` 的兜底。 */
sourceRecipientName: string
/** `source_chat` 且来源会话没有可读名时,`displayName` 的兜底。 */
sourceDisplayName: string
/** `self` 但拿不到自身身份。 */
selfMissing: string
/** `contact` 但没选联系人。 */
contactNotChosen: string
/** `contact` 但联系人失效 / 不可发送。 */
contactUnavailable: string
}
/**
* 中性目标解析。**唯一的实现**。
*/
export function resolveAutomationTarget(
input: ResolveAutomationTargetInput,
messages: AutomationTargetMessages
): AutomationTargetResolution {
const type = input.targetType
if (!type || !isKnownTargetType(type, messages.options)) {
return { ok: false, error: messages.invalidTarget }
}
switch (type) {
case 'source_chat': {
const conversationId = resolveGroupConversationId(
String(input.sourceConversationId || ''),
input.contacts
)
if (!conversationId) return { ok: false, error: messages.sourceMissing }
const explicitName = String(input.sourceDisplayName || '').trim()
const resolvedName = resolveGroupDisplayName(conversationId, input.contacts)
const name = explicitName || resolvedName || messages.sourceRecipientName
const displayName = explicitName || resolvedName || messages.sourceDisplayName
return {
ok: true,
target: {
recipient: { type: 'group', id: conversationId, name },
displayName
}
}
}
case 'self': {
const selfWxid = String(input.selfWxid || '').trim()
// 明确报错,不 fallback。
if (!selfWxid) return { ok: false, error: messages.selfMissing }
return {
ok: true,
target: {
recipient: { type: 'contact', id: selfWxid, name: '我' },
displayName: '我'
}
}
}
case 'file_transfer':
return {
ok: true,
target: {
recipient: {
type: 'contact',
id: WECHAT_FILE_HELPER_USERNAME,
name: '文件传输助手'
},
displayName: '文件传输助手'
}
}
case 'contact': {
const contactId = String(input.contactId || '').trim()
if (!contactId) return { ok: false, error: messages.contactNotChosen }
const contact = input.contacts.find(
(item) => String(item.m_nsUsrName || '').trim() === contactId
)
if (!contact || !isSendableFriendContact(contact, input.selfWxid)) {
// 之前选择的联系人被删除 / 不可发送 / 找不到时,规则不偷偷改发别处。
return { ok: false, error: messages.contactUnavailable }
}
const displayName = automationContactDisplayName(contact)
return {
ok: true,
target: {
recipient: { type: 'contact', id: contactId, name: displayName },
displayName
}
}
}
}
}
/** 退群通知的已知目标类型表(与文案同源)。 */
export const LEAVE_NOTIFICATION_TARGET_TYPES = LEAVE_NOTIFICATION_TARGET_OPTIONS
/** 定时日报的已知目标类型表(与文案同源)。 */
export const SCHEDULED_REPORT_TARGET_TYPES = SCHEDULED_REPORT_TARGET_OPTIONS
export { leaveNotificationTargetLabel, scheduledReportTargetLabel }
-14
View File
@@ -1036,20 +1036,6 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
}
}
/**
* 把群会话 md5 解析成原生接口真正需要的 roomId(`xxx@chatroom`)。
*
* 存在的理由:群员统计从界面拿到的是会话 md5(与 `getGroupSnapshot(userMd5)` 同口径),
* 而成员相关的原生接口吃的是 username。没有这层导出,调用方就得自己去碰
* `dbRef.getWcdb4Client()`,等于让服务层绕开 chat-service 的封装。
* 非群会话或解析不到时返回 null。
*/
export function resolveGroupRoomId(userMd5: string): string | null {
if (!dbRef) return null
const roomId = dbRef.getWcdb4Client().getUsernameByMd5(userMd5)
return roomId && roomId.endsWith('@chatroom') ? roomId : null
}
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
export async function getGroupMemberIdsAsync(
roomId: string
+200 -265
View File
@@ -2,17 +2,19 @@ import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import * as chat from './chat-service'
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
import {
buildMemberLeftNotification,
findRemovedGroupMembers,
groupExitMemberName,
normalizeGroupExitNotificationTemplate,
validateGroupExitNotificationTemplate,
type GroupExitMonitorEvent,
type GroupExitMonitorMember,
type GroupExitNotificationStatus,
type GroupExitNotificationState,
type GroupExitMonitorState
} from '../../shared/group-exit-monitor'
import {
toGroupMemberExitedEvent,
type GroupMemberExitedEvent
} from '../../shared/group-exit-event'
type StoredState = {
enabled?: boolean
@@ -21,14 +23,9 @@ type StoredState = {
lastReadAt?: number
monitorSelectionConfigured?: boolean
monitoredRoomIds?: string[]
notificationRoomIds?: string[]
notificationTemplate?: unknown
snapshots?: Partial<StoredGroupSnapshot>[]
/*
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
*
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
*/
}
type GroupSnapshotRecord = {
@@ -60,30 +57,19 @@ type StoredGroupSnapshot = Pick<
const DB_CHANGE_DEBOUNCE_MS = 350
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
/**
* **只约束「一次状态回传带多少条」**,不是存储上限。
*
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
*/
const MAX_EVENTS = 500
/**
* 退群事件处理器。
*
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
*/
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
export interface GroupExitMonitorServiceDependencies {
onGroupExit?: GroupExitEventHandler
actionGateway?: GroupExitActionGateway
}
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
Partial<
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
>
class GroupExitMonitorService {
private onGroupExit: GroupExitEventHandler | undefined
private readonly actionGateway: GroupExitActionGateway
private active = false
private enabled = true
private nativeMonitorActive = false
@@ -101,6 +87,7 @@ class GroupExitMonitorService {
private events: GroupExitMonitorEvent[] = []
private monitorSelectionConfigured = true
private monitoredRoomIds = new Set<string>()
private notificationRoomIds = new Set<string>()
private loaded = false
private accountRoot = ''
private groupNamesByRoomId = new Map<string, string>()
@@ -108,34 +95,24 @@ class GroupExitMonitorService {
private eventSequence = 0
private scopeGeneration = 0
private legacyFallbackLogged = false
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
this.onGroupExit = deps.onGroupExit
}
/**
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
*
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
* 要等数据库解锁后才存在,而本服务是模块级单例。
*/
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
this.onGroupExit = handler
this.actionGateway = deps.actionGateway || wechatActionGateway
}
getState(): GroupExitMonitorState {
this.ensureLoaded()
return {
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
events: this.events.slice(0, MAX_EVENTS),
/** 永久保留的事件总数(`events` 只是最新的一批)。 */
totalEventCount: this.events.length,
events: [...this.events],
enabled: this.enabled,
running: this.enabled && this.active && chat.isReady(),
nativeMonitorActive: this.nativeMonitorActive,
monitoredGroupCount: this.snapshots.size,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
lastCheckedAt: this.lastCheckedAt,
lastReadAt: this.lastReadAt,
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
@@ -155,11 +132,12 @@ class GroupExitMonitorService {
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
(!this.accountRoot && this.snapshots.size > 0)
) {
this.actionGateway.clearMemberEvents?.()
this.events = []
this.rewriteEventsToDisk([])
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.snapshots.clear()
this.groupNamesByRoomId.clear()
}
@@ -230,116 +208,75 @@ class GroupExitMonitorService {
return this.getState()
}
/**
* 保存**监控范围**。
*
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
* 本服务不再持有任何通知配置。
*/
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> {
async setMonitoredRoomIds(
roomIds: string[],
notificationRoomIds: string[] = []
): Promise<GroupExitMonitorState> {
this.ensureLoaded()
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
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)
}
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
}
for (const roomId of this.hydrationQueue) {
if (!nextMonitoredRoomIds.has(roomId)) this.hydrationQueue.delete(roomId)
}
this.monitoredRoomIds = nextMonitoredRoomIds
this.groupNamesRefreshPending = true
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
this.notificationRoomIds = new Set(
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
)
this.lastCheckedAt = undefined
this.save()
this.broadcast()
// 仅修改范围时保持原有的后台基线行为;涉及 enabled 的 PATCH 等待一次检查,
// 让调用方拿到的是最终运行状态。
if (this.enabled && this.active && chat.isReady()) {
if (hasEnabled) await this.check()
else void this.check()
}
// 建立基线放到后台,保存配置可以立即返回。
if (this.enabled && this.active) void this.check()
return this.getState()
}
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
return this.configure({ enabled })
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()
}
/**
* 按群 / 时间范围查退群事件(档案合并展示用)。
*
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
* 直接打在内存里的完整历史上(事件是永久保留的)。
*
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
*/
listEvents(
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
): GroupExitMonitorEvent[] {
setNotificationTemplate(value: unknown): GroupExitMonitorState {
this.ensureLoaded()
const roomId = String(query.roomId || '').trim()
const since = Number(query.sinceMs)
const until = Number(query.untilMs)
const limit = Number(query.limit)
// this.events 是倒序(新在前)。
let result = [...this.events].reverse()
if (roomId) result = result.filter((event) => event.roomId === roomId)
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
// 超量时保留**最近**的一批(尾部即最新)。
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
result = result.slice(-limit)
const result = validateGroupExitNotificationTemplate(value)
if (!result.valid || !result.template) {
throw new Error(result.error || '退群监测模板无效')
}
return result
this.notificationTemplate = result.template
this.save()
this.broadcast()
return this.getState()
}
clearEvents(): GroupExitMonitorState {
this.ensureLoaded()
this.events = []
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
this.rewriteEventsToDisk([])
this.actionGateway.clearMemberEvents?.()
this.lastReadAt = Date.now()
this.save()
this.broadcast()
@@ -462,7 +399,7 @@ class GroupExitMonitorService {
groups: GroupMembershipRecord[],
scopeGeneration: number
): Promise<number> {
const exits: GroupExitMonitorEvent[] = []
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
let changedGroups = 0
for (const membership of groups) {
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
@@ -493,9 +430,10 @@ class GroupExitMonitorService {
if (previous && next.members.length < previous.members.length) {
const removed = findRemovedGroupMembers(previous.members, next.members)
for (const member of removed) {
// 一人一条事件、一条通知 —— 保持既有产品语义,不聚合。
const event = this.recordExit(next, member, previous.members.length, next.members.length)
if (event) exits.push(event)
if (event && this.notificationRoomIds.has(next.roomId)) {
notifications.push({ group: next, event })
}
}
}
@@ -510,11 +448,12 @@ class GroupExitMonitorService {
return changedGroups
}
this.lastCheckedAt = Date.now()
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
this.save()
this.broadcast()
for (const event of exits) this.emitGroupExit(event)
if (notifications.length) {
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
}
return changedGroups
}
@@ -650,145 +589,129 @@ class GroupExitMonitorService {
currentCount,
delta: currentCount - previousCount,
message,
detectedAt
detectedAt,
notificationStatus: 'not_requested'
}
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
this.events = [event, ...this.events]
this.appendEventsToDisk([event])
this.actionGateway.registerMemberEvent?.(event)
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
console.log(
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
)
return event
}
/**
* 把退群事件交给自动化 —— **不等待**。
*
* 三条约束:
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
*/
private emitGroupExit(event: GroupExitMonitorEvent): void {
const handler = this.onGroupExit
if (!handler) return
private async notifyGroup(
group: GroupSnapshotRecord,
event: GroupExitMonitorEvent,
idempotencyKey = `member_left_notification:${event.id}`
): Promise<void> {
event.notificationStatus = 'pending'
event.notification = { status: 'pending' }
this.save()
this.broadcast()
try {
void Promise.resolve(
handler(toGroupMemberExitedEvent(event))
).catch((error) => {
console.warn(
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
)
const result = await this.actionGateway.execute({
idempotencyKey,
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: event.id,
recipient: {
type: 'group',
id: group.roomId,
name: group.groupName
},
content: {
type: 'text',
text: buildMemberLeftNotification(event, this.notificationTemplate)
},
metadata: {
memberId: event.memberWxid,
memberName: event.memberName,
detectedAt: event.detectedAt,
eventType: 'member_left',
eventRoomId: event.roomId
}
})
const notification: GroupExitNotificationState = {
status: result.status,
actionId: result.actionId,
decision: result.decision,
...(result.errorCode ? { errorCode: result.errorCode } : {}),
...(result.reason ? { reason: result.reason } : {}),
startedAt: result.startedAt,
finishedAt: result.finishedAt
}
event.notificationStatus = result.status
event.notification = notification
if (result.status !== 'sent') {
console.warn(
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
)
}
} catch (error) {
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
event.notificationStatus = 'failed'
event.notification = {
status: 'failed',
errorCode: 'UNKNOWN',
reason: error instanceof Error ? error.message : String(error)
}
console.warn(
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
error instanceof Error ? error.message : String(error)
)
} finally {
this.save()
this.broadcast()
}
}
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
const event = this.events.find((item) => item.id === eventId)
if (!event) throw new Error('退群动态不存在')
await this.notifyGroup(
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
event,
`member_left_notification:${event.id}:retry:${Date.now()}`
)
return this.getState()
}
private filePath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
}
/**
* 退群事件的 append-only 存储(每行一条 JSON)。
*
* 与状态文件分开,因为两者的写入模式完全不同:
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
* - **事件**只增不改,且要求**永久保留**。
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
*/
private eventsPath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
}
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
private readEventsFromDisk(): GroupExitMonitorEvent[] {
let raw = ''
try {
raw = fs.readFileSync(this.eventsPath(), 'utf8')
} catch {
return []
}
const events: GroupExitMonitorEvent[] = []
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
try {
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
} catch {
// 跳过坏行
}
}
return events
}
/** 只追加新增的那几行,不重写历史。 */
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
if (!events.length) return
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 追加退群事件失败:', error)
}
}
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 重写退群事件失败:', error)
}
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const stored = fs.readJsonSync(this.filePath()) as StoredState
this.enabled = stored.enabled !== false
// 事件从 append-only 文件读;状态文件不再承载它们。
const fromDisk = this.readEventsFromDisk()
const legacy = normalizeEvents(stored.events)
if (legacy.length && !fromDisk.length) {
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
// 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在
// append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。
this.rewriteEventsToDisk(
[...legacy].sort((left, right) => left.detectedAt - right.detectedAt)
)
this.events = sortEventsNewestFirst(legacy)
} else {
// 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
}
this.events = normalizeEvents(stored.events)
this.actionGateway.registerMemberEvents?.(this.events)
this.lastReadAt = Number(stored.lastReadAt) || 0
this.accountRoot = String(stored.accountRoot || '')
// 没有显式范围时按空范围处理,保留已有选择。
this.monitorSelectionConfigured = true
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
this.notificationRoomIds = new Set(
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
this.monitoredRoomIds.has(roomId)
)
)
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
stored.notificationTemplate
)
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
} catch {
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
// 不该被状态文件的问题连累,仍然读回来。
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
// 首次启动或文件损坏时从空记录开始。
this.events = []
this.enabled = true
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
this.snapshots.clear()
}
}
@@ -802,11 +725,12 @@ class GroupExitMonitorService {
{
accountRoot: this.accountRoot,
enabled: this.enabled,
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
events: this.events,
lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
},
{ spaces: 2 }
@@ -1003,28 +927,39 @@ function normalizeEvents(
? Number(value.delta)
: currentCount - previousCount,
message: String(value.message || `${memberName}退出了${groupName}`),
detectedAt
detectedAt,
...(value.notificationStatus
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
: {}),
...(value.notification && typeof value.notification === 'object'
? { notification: normalizeNotification(value.notification) }
: {})
})
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
if (normalized.length >= MAX_EVENTS) break
}
return normalized
}
/**
* 事件在内存里恒定保持「**新在前**」。
*
* 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、
* `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、
* 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。
*
* 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」
* 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的
* 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。
*/
function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] {
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
const status = String(value || '').trim()
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
? status
: 'not_requested'
}
function normalizeNotification(value: object): GroupExitNotificationState {
const input = value as Partial<GroupExitNotificationState>
return {
status: normalizeNotificationStatus(input.status),
...(input.actionId ? { actionId: String(input.actionId) } : {}),
...(input.decision === 'allow' || input.decision === 'block'
? { decision: input.decision }
: {}),
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
...(input.reason ? { reason: String(input.reason) } : {}),
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
}
}
function isContactEvent(rawPayload: string): boolean {
-208
View File
@@ -1,208 +0,0 @@
import { isKnowledgeFresh } from '../../shared/knowledge'
import {
GROUP_STATS_LIMITATION,
GROUP_STATS_STALE_LIMITATION,
type GroupMemberStatsActiveMember,
type GroupMemberStatsQuery,
type GroupMemberStatsResult,
type GroupMemberStatsSilentMember,
type GroupStatsFreshness
} from '../../shared/group-stats'
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import * as chat from './chat-service'
/**
* 触发一次追赶同步的最小间隔。
*
* 面板是可以反复开关的交互入口,而追赶同步跑的是真实增量 pass(会读 WCDB)。
* 没有这个节流,连续点击就等于连续触发索引。
*/
const CATCH_UP_MIN_INTERVAL_MS = 30_000
/**
* 追赶的有界等待预算。
*
* 与 Query 侧同一取舍:全量追赶可能以分钟计,交互查询绝不能无限等。
* 预算用完之后**如实**把结果标成不完整,而不是假装完整。
*/
const FRESHNESS_WAIT_BUDGET_MS = 2_000
/**
* 群员统计。
*
* 数据两路来源,职责严格分开:
* - **当前成员名单** 走 WCDB 的轻路径(只取 wxid + 显示名,**不** hydrate 头像);
* - **发言聚合** 走 Knowledge 派生库的 `GROUP BY sender_id`,不读 WCDB 原始消息。
*
* 「未发言」= 当前成员 ∖ 窗口内有发言的 sender。这个定义**不能**简化成
* 「整段窗口都没说话」—— 入群/退群时间在源头就不存在(整个 DB 没有该字段),
* 所以结论必须带上 limitation。
*/
export class GroupStatsService {
constructor(private readonly knowledge: KnowledgeSearchService) {}
async getMemberStats(query: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> {
const { userMd5, startTime, endTime } = query
const limitations = [GROUP_STATS_LIMITATION]
const roomId = chat.resolveGroupRoomId(userMd5)
if (!roomId) {
// 不是群 / md5 解析不到:如实返回空,不编造统计。
return this.build({
userMd5,
startTime,
endTime,
freshness: 'unknown',
limitations,
members: [],
activeMembers: [],
silentMembers: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: null
})
}
// 1) 当前群成员 —— 轻路径。只取 wxid,再按需解析显示名;绝不走
// `getGroupSnapshotAsync`(它会 materialize 整群并 hydrate 头像,实测 8 群 ≈ 42s)。
const membership = await chat.getGroupMemberIdsAsync(roomId)
const memberIds = membership?.memberIds ?? []
const members = await chat.getGroupMemberNamesAsync(userMd5, memberIds)
// 2) 发言聚合(只统计当前群;系统消息在 SQL 层就被排除)
let { result, sourceLatestAt } = await this.knowledge.memberStats({
conversationId: userMd5,
startTime,
endTime
})
let freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
// 3) 索引没追平 → 有界追赶一次再判。追不上就如实标 stale,
// **不允许**把落后索引算出来的「未发言」包装成完整结论。
if (freshness !== 'fresh') {
this.knowledge.requestCatchUp(CATCH_UP_MIN_INTERVAL_MS)
const settled = await this.knowledge.waitForIndexingComplete(FRESHNESS_WAIT_BUDGET_MS)
if (settled) {
const retried = await this.knowledge.memberStats({
conversationId: userMd5,
startTime,
endTime
})
result = retried.result
sourceLatestAt = retried.sourceLatestAt
freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
}
}
// 4) 差集:当前成员 × 窗口内发言
const statBySender = new Map<string, { messageCount: number; lastMessageTime: number }>()
for (const row of result?.senders ?? []) {
if (row.senderId) statBySender.set(row.senderId, row)
}
const knownSenderIds = new Set<string>()
const activeMembers: GroupMemberStatsActiveMember[] = []
const silentMembers: GroupMemberStatsSilentMember[] = []
for (const member of members) {
const wxid = String(member.wxid || '').trim()
if (!wxid) continue
knownSenderIds.add(wxid)
// 与 Knowledge 的 Join key 必须是 wxid,不是昵称 —— 昵称会变。
// 显示名沿用项目既有优先级(nickname 已内含 `wechatNickname || groupNickname || username`)。
const displayName = member.nickname || member.remark || wxid
const groupNickname = member.groupNickname || ''
const stat = statBySender.get(wxid)
if (stat && stat.messageCount > 0) {
activeMembers.push({
senderId: wxid,
displayName,
groupNickname,
messageCount: stat.messageCount,
lastMessageTime: stat.lastMessageTime
})
} else {
silentMembers.push({ senderId: wxid, displayName, groupNickname })
}
}
// 窗口内有发言、但已不在当前成员名单里的人(退群者)。
// 默认**不**进活跃榜:那份名单读作「当前群成员」,混入已退群的人会误导。
let formerSenderCount = 0
for (const senderId of statBySender.keys()) {
if (!knownSenderIds.has(senderId)) formerSenderCount += 1
}
activeMembers.sort(
(a, b) => b.messageCount - a.messageCount || b.lastMessageTime - a.lastMessageTime
)
silentMembers.sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'))
if (freshness !== 'fresh') limitations.push(GROUP_STATS_STALE_LIMITATION)
if (formerSenderCount > 0) {
limitations.push(
`另有 ${formerSenderCount} 位窗口内发言者已不在当前群成员名单中,未计入活跃榜。`
)
}
return this.build({
userMd5,
startTime,
endTime,
freshness,
limitations,
members,
activeMembers,
silentMembers,
unattributedMessages: result?.unattributedMessages ?? 0,
excludedSystemMessages: result?.excludedSystemMessages ?? 0,
firstMessageTime: result?.earliestMessageTime ?? null
})
}
private build(input: {
userMd5: string
startTime: number
endTime: number
freshness: GroupStatsFreshness
limitations: string[]
members: Array<{ wxid: string }>
activeMembers: GroupMemberStatsActiveMember[]
silentMembers: GroupMemberStatsSilentMember[]
unattributedMessages: number
excludedSystemMessages: number
firstMessageTime: number | null
}): GroupMemberStatsResult {
return {
conversationId: input.userMd5,
startTime: input.startTime,
endTime: input.endTime,
freshness: input.freshness,
// 只有索引确实追平,才允许调用方把「未发言」当作完整结论。
complete: input.freshness === 'fresh',
memberCount: input.members.length,
activeMemberCount: input.activeMembers.length,
silentMemberCount: input.silentMembers.length,
activeMembers: input.activeMembers,
silentMembers: input.silentMembers,
unattributedMessages: input.unattributedMessages,
excludedSystemMessages: input.excludedSystemMessages,
firstMessageTime: input.firstMessageTime,
limitations: input.limitations
}
}
}
/**
* `isKnowledgeFresh` 返回 `boolean | null`(任一侧口径缺失即 null)。
* 三态都要能表达:`null` 绝不能当成「新鲜」。
*/
function judgeFreshness(
indexLatestAt: number | null,
sourceLatestAt: number | null
): GroupStatsFreshness {
const fresh = isKnowledgeFresh({ indexLatestAt, sourceLatestAt })
if (fresh === true) return 'fresh'
if (fresh === false) return 'stale'
return 'unknown'
}
@@ -1,116 +0,0 @@
import {
normalizeLeaveNotificationRoomIds,
normalizeLeaveNotificationTemplate,
type LeaveNotificationNotifyScope,
type LeaveNotificationTarget
} from '../../shared/automation'
/**
* 旧「退群监控 → 通知群聊 / 模板」配置 → 新「自动化 → 退群通知」规则的**纯映射器**。
*
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
* 不允许只靠"跑一遍看看"来验证。
*
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
*
* 旧版是**两层**配置,缺一不可:
* 1. **管理群聊**(`monitoredRoomIds`)= 监测哪些群有人退出;
* 2. **通知群聊**(`notificationRoomIds`)= 上面这批群里,**哪些要真的发通知**
* —— 即对第 1 层的二次勾选。
*
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
* - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知;
* - `notifyGroup()` 的收件人**恒等于事件所在群**。
*
* ## 新语义
*
* 拆成**两个正交维度**,恰好一一对应旧版那两层:
* - `target` ← 旧版第 2 层里"发回本群"这个事实 ⇒ 恒为 `source_chat`;
* - `notifyScope` / `notifyRoomIds` ← 旧版第 2 层是"哪些群",逐字保留。
*
* ⇒ **迁移现在是无损的**。早期版本把目标压成单值、丢掉了第 2 层的"哪些群",
* 于是部分勾选只能判成 `needs_review` 并强制停用;恢复 `notifyScope` 之后
* 这个特例整体消失,用户不会再有"规则被无辜停用"的体验。
*/
export interface LegacyLeaveNotificationState {
monitoredRoomIds: string[]
notificationRoomIds: string[]
notificationTemplate?: unknown
}
export type LeaveNotificationMigrationOutcome =
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
| 'lossless_off'
/** 旧配置在**每个**被监控群里通报 ⇒ `notifyScope: 'all'`,行为完全一致。 */
| 'lossless_all_groups'
/** 旧配置只在**部分**群里通报 ⇒ `notifyScope: 'selected'` + 原样保留那份勾选。 */
| 'lossless_selected_groups'
export interface LeaveNotificationMigrationPlan {
outcome: LeaveNotificationMigrationOutcome
enabled: boolean
target: LeaveNotificationTarget
notifyScope: LeaveNotificationNotifyScope
notifyRoomIds: string[]
targetNeedsReview: boolean
template: string
}
function uniqueRoomIds(values: unknown): string[] {
return normalizeLeaveNotificationRoomIds(values)
}
export function planLeaveNotificationMigration(
legacy: LegacyLeaveNotificationState
): LeaveNotificationMigrationPlan {
const monitored = uniqueRoomIds(legacy.monitoredRoomIds)
const monitoredSet = new Set(monitored)
// 再夹一次:防止脏状态文件把范围外的群带进来。
const notifications = uniqueRoomIds(legacy.notificationRoomIds).filter((roomId) =>
monitoredSet.has(roomId)
)
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate)
// 旧版收件人恒为事件所在群 ⇒ 无论勾了几个群,目标都是 source_chat。
const target: LeaveNotificationTarget = { type: 'source_chat' }
if (!notifications.length) {
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
// 同时把范围写成 `selected` + 空集:配置本身也精确表达"一个都不通知"。
return {
outcome: 'lossless_off',
enabled: false,
target,
notifyScope: 'selected',
notifyRoomIds: [],
targetNeedsReview: false,
template
}
}
if (monitored.length > 0 && notifications.length === monitored.length) {
// 每个被监控群都在自己群里通报 ⇒ 等价于"全部已监控群聊"。
return {
outcome: 'lossless_all_groups',
enabled: true,
target,
notifyScope: 'all',
notifyRoomIds: [],
targetNeedsReview: false,
template
}
}
// 部分勾选:**无损**保留那份子集,不再降级成 needs_review。
return {
outcome: 'lossless_selected_groups',
enabled: true,
target,
notifyScope: 'selected',
notifyRoomIds: notifications,
targetNeedsReview: false,
template
}
}
@@ -1,91 +0,0 @@
import {
LEAVE_NOTIFICATION_TARGET_OPTIONS,
leaveNotificationTargetLabel,
type LeaveNotificationConfig
} from '../../shared/automation'
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
import {
automationContactDisplayName,
filterSendableFriendContacts as filterSendable,
isSendableFriendContact as isSendable,
resolveAutomationTarget,
type AutomationTargetContact,
type AutomationTargetResolution,
type ResolvedAutomationTarget
} from './automation-wechat-target'
/**
* 退群通知的**目标解析**(薄适配层)。
*
* 真正的判定在 `automation-wechat-target.ts` —— 那里才是**唯一实现**,
* 定时日报与退群通知共用它。这里只有两件事:
* 1. 把 `LeaveNotificationConfig` + 退群事件摊平成中性输入;
* 2. 提供退群通知专属文案,保持既有调用方 / 测试不变。
*
* ⚠️ 这里**不允许**再出现一份 switch / 判定逻辑 —— 两套并存迟早分叉。
*/
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
export type LeaveNotificationCandidateContact = AutomationTargetContact
export type ResolvedLeaveNotificationTarget = ResolvedAutomationTarget
export type LeaveNotificationTargetResolution = AutomationTargetResolution
/** 退群通知的目标文案(唯一一份)。 */
export const LEAVE_NOTIFICATION_TARGET_MESSAGES = {
options: LEAVE_NOTIFICATION_TARGET_OPTIONS,
invalidTarget: '退群通知的发送目标无效,请重新选择',
sourceMissing: '无法确定发生退群的群聊,本次通知未发送',
sourceRecipientName: '当前群聊',
sourceDisplayName: '发生退群事件的群聊',
selfMissing: '无法确定当前登录的微信账号,本次通知未发送',
contactNotChosen: '还没有选择通知联系人,请重新选择',
contactUnavailable: '通知联系人已不存在或当前无法发送,请重新选择'
} as const
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
export function leaveNotificationContactDisplayName(contact: AutomationTargetContact): string {
return automationContactDisplayName(contact)
}
/** 这个联系人能不能作为「指定好友」的发送目标(与定时日报共用同一条判定)。 */
export function isSendableFriendContact(
contact: AutomationTargetContact,
selfWxid?: string
): boolean {
return isSendable(contact, selfWxid)
}
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
contacts: T[],
selfWxid?: string
): T[] {
return filterSendable(contacts, selfWxid)
}
export function resolveLeaveNotificationTarget(input: {
config: LeaveNotificationConfig | undefined
event: GroupMemberExitedEvent
contacts: LeaveNotificationCandidateContact[]
/** 当前登录账号的 wxid;拿不到时传空串。 */
selfWxid?: string
}): LeaveNotificationTargetResolution {
const config = input.config
if (!config) return { ok: false, error: '退群通知尚未配置发送目标' }
return resolveAutomationTarget(
{
targetType: config.target?.type as never,
sourceConversationId: String(input.event.conversationId || '').trim(),
sourceDisplayName: String(input.event.groupName || '').trim(),
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
contacts: input.contacts,
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
},
LEAVE_NOTIFICATION_TARGET_MESSAGES
)
}
/** 目标类型 → 界面短标签(保持既有导出面)。 */
export { leaveNotificationTargetLabel }
@@ -1,967 +0,0 @@
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'
)
}
}
}
+12 -27
View File
@@ -46,21 +46,6 @@ 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
@@ -86,17 +71,18 @@ export function buildLocalApiCurlCommand(payload: unknown): {
const service = apiServer.getState()
const targetHost = requestHost(service.host)
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
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 url = new URL(endpoint.path, `http://${hostPart}:${service.port}`)
entries.forEach(([key, value]) => {
if (value.trim()) url.searchParams.set(key, value.trim())
})
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' || endpoint.method === 'PATCH'
? `curl -X ${endpoint.method} '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
endpoint.method === 'POST'
? `curl -X POST '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
: `curl '${url.toString()}'${authHeader}`
return { success: true, command }
}
@@ -124,9 +110,10 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
const targetHost = requestHost(service.host)
const targetPort = service.port
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
const endpointPath = materializeEndpointPath(endpoint.path, query as Record<string, string>)
const url = new URL(endpointPath, `http://${hostPart}:${targetPort}`)
appendQueryParameters(url, endpoint.path, entries)
const url = new URL(endpoint.path, `http://${hostPart}:${targetPort}`)
entries.forEach(([key, value]) => {
if (value.trim()) url.searchParams.set(key, value.trim())
})
if (!service.running) {
return {
@@ -163,9 +150,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
})
}
const headers: Record<string, string> = {}
if (endpoint.method === 'POST' || endpoint.method === 'PATCH') {
headers['Content-Type'] = 'application/json'
}
if (endpoint.method === 'POST') headers['Content-Type'] = 'application/json'
if (token) headers.Authorization = `Bearer ${token}`
const request = http.request(
url,
@@ -223,7 +208,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
error: error.message
})
})
if (endpoint.method === 'POST' || endpoint.method === 'PATCH') request.write(body)
if (endpoint.method === 'POST') request.write(body)
request.end()
})
}
@@ -1,101 +0,0 @@
import { existsSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import type { MacWechatRuntimeManifest } from '../../shared/personal-wechat-mac-runtime'
const REQUIRED_CAPABILITIES = ['text', 'image', 'voice'] as const
function assertFile(path: string, label: string): void {
if (!existsSync(path) || !statSync(path).isFile()) {
throw new Error(`${label} missing: ${path}`)
}
}
function assertMachOArm64(path: string, label: string): void {
const binary = readFileSync(path)
if (binary.length < 8 || binary.readUInt32LE(0) !== 0xfeedfacf) {
throw new Error(`${label} is not a 64-bit Mach-O binary`)
}
if (binary.readUInt32LE(4) !== 0x0100000c) {
throw new Error(`${label} is not arm64`)
}
}
export function readMacWechatRuntimeManifest(
runtimeDir: string,
architecture = process.arch
): MacWechatRuntimeManifest {
const manifestPath = join(runtimeDir, 'runtime-manifest.json')
const hostPath = join(runtimeDir, 'tm-wechat-host')
const dylibPath = join(runtimeDir, 'libtmwechat.dylib')
assertFile(manifestPath, 'runtime manifest')
assertFile(hostPath, 'runtime host')
assertFile(dylibPath, 'runtime dylib')
let manifest: MacWechatRuntimeManifest
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as MacWechatRuntimeManifest
} catch (error) {
throw new Error(`runtime manifest is unreadable: ${String(error)}`)
}
if (manifest.runtime !== 'tm-wechat-native') {
throw new Error(`unexpected runtime name: ${String(manifest.runtime)}`)
}
if (manifest.platform !== 'darwin-arm64') {
throw new Error(`runtime platform must be darwin-arm64: ${String(manifest.platform)}`)
}
if (manifest.architecture !== 'arm64' || architecture !== 'arm64') {
throw new Error(
`runtime architecture mismatch: artifact=${String(manifest.architecture)} process=${architecture}`
)
}
if (manifest.protocolVersion !== 1) {
throw new Error(`unsupported runtime protocolVersion: ${String(manifest.protocolVersion)}`)
}
if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) {
throw new Error('runtime manifest has no valid tmSendSourceSha256')
}
if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) {
throw new Error('runtime manifest has no valid addressProfileSha256')
}
if (
!Array.isArray(manifest.supportedWechatVersions) ||
manifest.supportedWechatVersions.length === 0
) {
throw new Error('runtime manifest has no supported WeChat versions')
}
const capabilities = new Set(manifest.capabilities || [])
for (const capability of REQUIRED_CAPABILITIES) {
if (!capabilities.has(capability)) {
throw new Error(`runtime manifest capability missing: ${capability}`)
}
}
assertMachOArm64(hostPath, 'runtime host')
assertMachOArm64(dylibPath, 'runtime dylib')
if (!readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) {
throw new Error('runtime dylib embedded agent hash does not match manifest')
}
if (!readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) {
throw new Error('runtime dylib embedded address profile hash does not match manifest')
}
return manifest
}
export function bindingMatchesRuntimeManifest(
binding: {
hostPid?: number
runtimeVersion?: string
embeddedAgentSha256?: string
embeddedAddressProfileSha256?: string
},
manifest: MacWechatRuntimeManifest
): boolean {
return (
Number.isInteger(binding.hostPid) &&
Number(binding.hostPid) > 0 &&
binding.runtimeVersion === `tm-wechat-runtime/${manifest.version}` &&
binding.embeddedAgentSha256 === manifest.tmSendSourceSha256 &&
binding.embeddedAddressProfileSha256 === manifest.addressProfileSha256
)
}
@@ -1,724 +0,0 @@
import { spawn, type ChildProcess } from 'child_process'
import { chmodSync, existsSync } from 'fs'
import { get } from 'http'
import { join } from 'path'
import { app } from 'electron'
import { appLogger } from '../app-logger'
import { isPackagedRuntime } from '../runtime-mode'
import type {
MacWechatBindResult,
MacWechatBindingStatus,
MacWechatRuntimeManifest,
MacWechatRuntimeLifecycle,
MacWechatRuntimeSnapshot
} from '../../shared/personal-wechat-mac-runtime'
import type { PersonalWechatSenderStatus } from '../../shared/personal-wechat'
import {
bindingMatchesRuntimeManifest,
readMacWechatRuntimeManifest
} from './mac-wechat-runtime-artifact'
/*
* MacWechatRuntimeManager — lifecycle owner of the built-in macOS native
* WeChat runtime (tm-wechat-host + libtmwechat.dylib).
*
* Process isolation is a hard requirement: Electron NEVER dlopens the dylib.
* It spawns tm-wechat-host, which is the ONLY binding owner, and talks to it
* over 127.0.0.1 HTTP. A native crash therefore cannot take Electron down.
*
* Binding lifecycle is "bind once per WeChat process":
* - starting the host NEVER attaches to WeChat (the host is lazy)
* - bind happens only on an explicit user action
* - a host crash while bound leaves an orphaned frida agent inside WeChat
* that frida cannot remove from the outside; the only recovery is
* restarting WeChat. Surfaced as wechatRestartRequired. This was verified
* experimentally (2026-09-21) — do not "fix" it by auto-restarting and
* auto-binding, that path dead-ends in a 20s attach timeout every time.
*/
const PORT = 4290
const HOST = '127.0.0.1'
const START_TIMEOUT_MS = 15_000
const REQUEST_TIMEOUT_MS = 30_000
const CRASH_RESTART_DELAY_MS = 1_500
interface MacWechatSenderStatusInput {
snapshot: MacWechatRuntimeSnapshot
binding: MacWechatBindingStatus | null
manifest: MacWechatRuntimeManifest
arch?: string
}
export function shouldClearWechatRestartRequired(
orphanWechatPid: number,
currentWechatPid: number
): boolean {
return orphanWechatPid > 0 && currentWechatPid > 0 && orphanWechatPid !== currentWechatPid
}
export function deriveMacWechatSenderStatus({
snapshot,
binding,
manifest,
arch = process.arch
}: MacWechatSenderStatusInput): PersonalWechatSenderStatus {
const identityMatches = binding !== null && bindingMatchesRuntimeManifest(binding, manifest)
const stale = binding?.bindingState === 'stale'
let state: PersonalWechatSenderStatus['state'] = 'stopped'
let message = '正在初始化微信发送能力'
if (snapshot.wechatRestartRequired) {
state = 'error'
message = '微信发送组件异常退出。由于当前微信进程中可能残留上一发送会话,请重启微信后重新绑定。'
} else if (binding && !identityMatches) {
state = 'error'
message = '运行中的微信发送 host 与当前 Native Runtime 不一致,请退出旧 host 后重启 TraceMemo。'
} else if (binding && !binding.wechatRunning) {
state = 'wechat_not_running'
message = '微信未运行'
} else if (binding?.bindingState === 'ready') {
state = 'online'
message = '个人微信已准备好发送日报'
} else if (binding?.bindingState === 'bound' || binding?.bindingState === 'binding') {
state = 'hook_not_ready'
message = '已绑定,等待捕获微信发送上下文'
} else if (stale) {
state = 'stopped'
message = '检测到微信已重启,请重新绑定'
} else if (binding?.bindingState === 'failed') {
state = 'error'
message = '微信绑定失败'
} else {
state = 'stopped'
message = '尚未绑定当前微信,可点击"绑定微信"'
}
const runtimeCapabilities = new Set(manifest.capabilities)
const bindingReady = identityMatches && state === 'online' && binding?.sendContextReady === true
const canSendText =
bindingReady && runtimeCapabilities.has('text') && binding?.canSendText === true
const canSendImage =
bindingReady && runtimeCapabilities.has('image') && binding?.canSendImage === true
const canSendVoice =
bindingReady && runtimeCapabilities.has('voice') && binding?.canSendVoice === true
const sessionAttached = identityMatches && (binding?.sessionAttached ?? false)
const scriptLoaded = identityMatches && (binding?.scriptLoaded ?? false)
return {
state,
platform: 'darwin',
arch,
// 当前 gadget runtime 不依赖宿主 SIP 状态;该字段仅用于兼容旧 sender contract。
sipDisabled: true,
wechatRunning: binding?.wechatRunning ?? false,
wechatPid: binding?.wechatPid ?? 0,
boundWechatPid: binding?.boundWechatPid ?? 0,
endpoint: snapshot.endpoint,
endpointReady: snapshot.lifecycle === 'online',
wechatVersion: binding?.wechatVersion,
runtimeReady: snapshot.lifecycle === 'online' && identityMatches,
attachReady: sessionAttached,
baseAddressReady: sessionAttached,
textHookInstalled: scriptLoaded,
textHookReady: identityMatches && (binding?.sendContextReady ?? false),
imageHookInstalled: identityMatches && (binding?.canSendImage ?? false),
imageHookReady: identityMatches && (binding?.canSendImage ?? false),
messageListenerReady: scriptLoaded,
canSend: canSendText || canSendImage || canSendVoice,
canSendText,
canSendImage,
canSendVoice,
message,
error: state === 'error' ? message : undefined
}
}
class MacWechatRuntimeManager {
private child: ChildProcess | null = null
private lifecycle: MacWechatRuntimeLifecycle = 'stopped'
private wechatRestartRequired = false
/* The WeChat pid that was bound when the host died / bind hit the orphan
* wall. The orphan agent lives INSIDE that process; once WeChat is restarted
* the pid changes and the orphan is gone, so the flag must auto-clear. */
private orphanWechatPid = 0
private lastBinding: MacWechatBindingStatus | null = null
private manifest: MacWechatRuntimeManifest | null = null
private lastError: string | null = null
private restartTimer: NodeJS.Timeout | null = null
getPort(): number {
return PORT
}
getSnapshot(): MacWechatRuntimeSnapshot {
return {
lifecycle: this.lifecycle,
wechatRestartRequired: this.wechatRestartRequired,
endpoint: `http://${HOST}:${PORT}`,
port: PORT,
runtimeDir: this.resolveRuntimeDir(),
manifest: this.manifest ?? undefined,
error: this.lastError ?? undefined,
binding: this.lastBinding ?? undefined
}
}
private resolveRuntimeDir(): string {
const base = isPackagedRuntime() ? process.resourcesPath : app.getAppPath()
return join(base, 'resources', 'runtime', 'darwin-arm64')
}
private resolveHostPath(): string {
return join(this.resolveRuntimeDir(), 'tm-wechat-host')
}
/** Runtime binaries present and executable? Cheap check for the settings UI. */
isRuntimePresent(): boolean {
try {
this.manifest = readMacWechatRuntimeManifest(this.resolveRuntimeDir())
return true
} catch {
return false
}
}
/**
* Start the host if it is not already running. Deliberately lazy: the host
* comes up WITHOUT touching WeChat (lazy init inside the host), so "runtime
* online" and "WeChat bound" stay two independent states.
*/
async ensureStarted(): Promise<MacWechatRuntimeSnapshot> {
if (this.lifecycle === 'starting') {
return this.getSnapshot()
}
const runtimeDir = this.resolveRuntimeDir()
try {
this.manifest = readMacWechatRuntimeManifest(runtimeDir)
appLogger.write({
level: 'info',
scope: 'mac-wechat-runtime',
message: 'runtime_artifact_validated',
details: {
runtime: this.manifest.runtime,
version: this.manifest.version,
protocolVersion: this.manifest.protocolVersion,
agentSha256: this.manifest.tmSendSourceSha256,
addressProfileSha256: this.manifest.addressProfileSha256
}
})
} catch (error) {
this.manifest = null
const detail = error instanceof Error ? error.message : String(error)
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'runtime_artifact_unavailable',
details: { detail }
})
this.lastError = '暂无发送能力'
this.lifecycle = 'stopped'
return this.getSnapshot()
}
if (this.lifecycle === 'online') {
await this.refreshBinding().catch(() => undefined)
if (
this.lastBinding &&
this.manifest &&
bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
) {
return this.getSnapshot()
}
this.lifecycle = 'stopped'
this.lastError = '运行中的 host 与 runtime manifest 不一致'
return this.getSnapshot()
}
/*
* Adopt-first: the host is designed to OUTLIVE TraceMemo ("keep the send
* capability process"), so a previous app session may have left it running
* with a live, bound session. If something is already answering on our
* port, adopt it instead of spawning a second one (which would just die on
* the occupied port). Adopting is pure HTTP — the running host keeps its
* frida session, so the app is immediately ready with NO re-bind, exactly
* the "keep the send capability process" behaviour the user asked to carry over.
*/
if (await this.ping()) {
this.lifecycle = 'online'
this.child = null
this.wechatRestartRequired = false
await this.refreshBinding().catch(() => undefined)
if (
!this.lastBinding ||
!this.manifest ||
!bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
) {
this.lifecycle = 'stopped'
this.lastError =
'检测到无法确认身份的微信发送 host。请先退出旧 host,再重新启动 TraceMemo。'
return this.getSnapshot()
}
return this.getSnapshot()
}
if (this.lifecycle === 'crashed_bound' || this.wechatRestartRequired) {
// Restarting the host is safe (it does not touch WeChat), but binding is
// not: the orphaned agent from the previous session is still inside the
// current WeChat process. Start anyway so the UI can show live status.
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'restart_host_after_bound_crash',
details: { note: 'bind stays blocked until WeChat restarts' }
})
}
const hostPath = this.resolveHostPath()
if (!existsSync(hostPath)) {
this.lastError = `runtime host missing: ${hostPath}`
this.lifecycle = 'stopped'
return this.getSnapshot()
}
try {
chmodSync(hostPath, 0o755)
} catch {
/* best effort — dev checkouts usually keep the exec bit */
}
this.lifecycle = 'starting'
this.lastError = null
try {
/* Host stderr goes to /dev/null (stdio ignored, see note above), so the
* host writes its own log file via TM_WECHAT_LOG_FILE. Without this the
* agent's stage beacons (send-enter / cdn-enter / …) are invisible and a
* media/CDN stall is undiagnosable. info level so stage beacons show. */
const hostLogFile = join(app.getPath('logs'), 'tm-wechat-host.log')
this.child = spawn(
hostPath,
[
'--port',
String(PORT),
'--manifest',
join(runtimeDir, 'runtime-manifest.json'),
'--log-level',
'info'
],
{
cwd: runtimeDir,
stdio: 'ignore',
detached: false,
env: { ...process.env, TM_WECHAT_LOG_FILE: hostLogFile }
}
)
} catch (error) {
this.lifecycle = 'stopped'
this.lastError = String(error)
return this.getSnapshot()
}
this.child.on('exit', (code) => {
const wasBound =
this.lastBinding?.bound === true ||
this.lastBinding?.sessionAttached === true ||
this.lastBinding?.bindingState === 'binding'
const orphanPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
this.child = null
this.lastBinding = null
if (this.lifecycle === 'stopped') return // deliberate shutdown
if (wasBound) {
this.lifecycle = 'crashed_bound'
this.wechatRestartRequired = true
this.orphanWechatPid = orphanPid
appLogger.write({
level: 'error',
scope: 'mac-wechat-runtime',
message: 'host_died_while_bound',
details: { code: code ?? null, orphanWechatPid: orphanPid }
})
} else {
this.lifecycle = 'crashed_unbound'
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'host_died_while_unbound_auto_restart',
details: { code: code ?? null }
})
if (this.restartTimer) clearTimeout(this.restartTimer)
this.restartTimer = setTimeout(() => {
void this.ensureStarted()
}, CRASH_RESTART_DELAY_MS)
}
})
// Wait for /healthz
const deadline = Date.now() + START_TIMEOUT_MS
while (Date.now() < deadline) {
if (await this.ping()) {
this.lifecycle = 'online'
await this.refreshBinding().catch(() => undefined)
if (
!this.lastBinding ||
!this.manifest ||
!bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
) {
this.lifecycle = 'crashed_unbound'
this.lastError = '运行中的 host 与 runtime manifest 不一致'
return this.getSnapshot()
}
return this.getSnapshot()
}
if (this.child === null) break // died during startup
await new Promise((resolve) => setTimeout(resolve, 300))
}
if (this.lifecycle === 'starting') {
this.lifecycle = 'crashed_unbound'
this.lastError = 'host did not answer /healthz in time'
}
return this.getSnapshot()
}
private request(
method: string,
path: string,
timeoutMs = REQUEST_TIMEOUT_MS
): Promise<{ status: number | null; body: string }> {
return new Promise((resolve) => {
const request = get(
{ host: HOST, port: PORT, path, method, timeout: timeoutMs },
(response) => {
let body = ''
response.on('data', (chunk) => {
body += chunk
})
response.on('end', () => {
resolve({ status: response.statusCode ?? null, body })
})
}
)
request.on('timeout', () => {
request.destroy()
resolve({ status: null, body: '' })
})
request.on('error', () => {
resolve({ status: null, body: '' })
})
request.end()
})
}
async ping(): Promise<boolean> {
const { status } = await this.request('GET', '/healthz', 3_000)
if (status === 200) return true
/*
* Adopted hosts have no child handle, so their death is only observable
* through failed probes. Classify here: dying while bound means WeChat
* keeps an orphaned agent (restart required); dying while unbound is
* auto-recoverable.
*/
if (this.lifecycle === 'online') {
const wasBound =
this.lastBinding?.bound === true ||
this.lastBinding?.sessionAttached === true ||
this.lastBinding?.bindingState === 'binding'
const orphanPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
this.lastBinding = null
if (wasBound) {
this.lifecycle = 'crashed_bound'
this.wechatRestartRequired = true
this.orphanWechatPid = orphanPid
appLogger.write({
level: 'error',
scope: 'mac-wechat-runtime',
message: 'adopted_host_died_while_bound',
details: { note: 'WeChat restart required', orphanWechatPid: orphanPid }
})
} else {
this.lifecycle = 'crashed_unbound'
}
}
return false
}
/** Pull /bindingStatus and cache it for crash classification. */
async refreshBinding(): Promise<MacWechatBindingStatus | null> {
const { status, body } = await this.request('GET', '/bindingStatus')
if (status !== 200) return null
try {
this.lastBinding = JSON.parse(body) as MacWechatBindingStatus
/*
* A 20s bind timeout is NOT proof of the orphan-agent wall: in the
* stale-rebind path the host worker waits up to 45s for the old session
* teardown before attaching, so the HTTP response times out while the
* attach quietly succeeds afterwards. When the binding is later observed
* alive and matching, the restart-required verdict was a false positive
* and must be withdrawn — otherwise the manager keeps rejecting every
* further bind locally while WeChat is actually bound and working.
*/
if (
this.wechatRestartRequired &&
(this.lastBinding.bound === true || this.lastBinding.bindingState === 'ready') &&
this.lastBinding.boundWechatPid === this.lastBinding.wechatPid
) {
this.wechatRestartRequired = false
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'restart_required_withdrawn',
details: { note: 'binding observed alive after bind timeout' }
})
}
return this.lastBinding
} catch {
return null
}
}
async getBindingStatus(): Promise<MacWechatBindingStatus | null> {
if (this.lifecycle !== 'online') return this.lastBinding
await this.refreshBinding().catch(() => null)
return this.lastBinding
}
/*
* Build the legacy PersonalWechatSenderStatus contract from the live mac
* runtime state. Lives here (not in the capability service) so BOTH the
* capability mapping and the send path report the same facts without a
* circular import.
*/
async buildSenderStatus(): Promise<PersonalWechatSenderStatus> {
/*
* Lazy-start the host from status reads.
*
* Every UI surface discovers the runtime through this method, and the
* setup guide renders "正在检查 发送运行时…" while runtimeReady is false —
* where runtimeReady means lifecycle === 'online'. If nothing here brings a
* stopped host up, opening the app (with WeChat already logged in, so no
* send is ever attempted) leaves that step spinning forever: the check can
* never succeed because the check itself is what should start the host.
* Starting it never touches WeChat, so doing it from a read is safe.
*/
if (this.lifecycle === 'stopped') {
await this.ensureStarted().catch(() => undefined)
}
/* Self-heal the status card too: if WeChat was restarted, drop the stale
* restartRequired before it is read into the UI as an error. */
try {
this.manifest = readMacWechatRuntimeManifest(this.resolveRuntimeDir())
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'runtime_artifact_unavailable',
details: { detail }
})
const message = '暂无发送能力'
this.manifest = null
this.lastError = message
return {
state: 'runtime_missing',
platform: 'darwin',
arch: process.arch,
sipDisabled: true,
wechatRunning: false,
endpoint: `http://${HOST}:${PORT}`,
endpointReady: false,
runtimeReady: false,
attachReady: false,
baseAddressReady: false,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message,
error: message
}
}
await this.clearRestartRequiredIfWechatRestarted()
const snapshot = this.getSnapshot()
const binding = await this.getBindingStatus()
return deriveMacWechatSenderStatus({ snapshot, binding, manifest: this.manifest })
}
/*
* The orphan agent lives inside ONE specific WeChat process. If the WeChat
* pid we recorded when restartRequired was raised no longer matches the
* WeChat pid the host currently sees, WeChat has been restarted and the
* orphan is gone — the flag must clear or every later bind is rejected
* forever even though the user DID restart WeChat (the reported bug).
*/
private async clearRestartRequiredIfWechatRestarted(): Promise<void> {
if (!this.wechatRestartRequired) return
await this.refreshBinding().catch(() => null)
const currentPid = this.lastBinding?.wechatPid ?? 0
if (shouldClearWechatRestartRequired(this.orphanWechatPid, currentPid)) {
this.wechatRestartRequired = false
this.orphanWechatPid = 0
if (this.lifecycle === 'crashed_bound') this.lifecycle = 'online'
appLogger.write({
level: 'info',
scope: 'mac-wechat-runtime',
message: 'restart_required_cleared_wechat_restarted',
details: { currentWechatPid: currentPid }
})
}
}
/**
* Explicit user action only. Idempotent on the host side: binding the same
* live WeChat pid again returns attached=false and never double-attaches.
*/
async bind(): Promise<MacWechatBindResult> {
if (this.lifecycle !== 'online') {
const snapshot = await this.ensureStarted()
if (snapshot.lifecycle !== 'online') {
return {
ok: false,
attached: false,
state: 'runtime_offline',
message: snapshot.error ?? 'Runtime 未在线,无法绑定'
}
}
}
/* Auto-clear if WeChat was restarted since the flag was raised —
* otherwise this branch rejects every bind forever (the reported bug). */
await this.clearRestartRequiredIfWechatRestarted()
if (this.wechatRestartRequired) {
return {
ok: false,
attached: false,
state: 'wechat_restart_required',
message:
'Previous WeChat send session was not fully released. ' + 'Restart WeChat and bind again.'
}
}
const { status, body } = await this.request('POST', '/bindWeChat')
await this.refreshBinding().catch(() => null)
if (status !== 200) {
return {
ok: false,
attached: false,
state: 'host_error',
message: `host 返回 HTTP ${status ?? '无响应'}`
}
}
try {
const parsed = JSON.parse(body) as MacWechatBindResult
if (!parsed.ok && parsed.message?.includes('not fully released')) {
this.wechatRestartRequired = true
this.orphanWechatPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
appLogger.write({
level: 'error',
scope: 'mac-wechat-runtime',
message: 'bind_hit_orphan_agent_wall',
details: { note: 'WeChat restart required', orphanWechatPid: this.orphanWechatPid }
})
}
return parsed
} catch {
return { ok: false, attached: false, state: 'bad_response', message: body.slice(0, 200) }
}
}
/*
* 重新加载发送组件:优雅停掉当前 host 进程,再启动一个新实例。
* 用于:更新二进制后换新代码;或 host 卡死时的手动恢复。
* 注意:若当前 host 已绑定微信,此操作会让微信里留下孤儿 agent,
* 需要重启微信后重新绑定(UI 会提示)。
*/
async reload(): Promise<MacWechatRuntimeSnapshot> {
await this.shutdown().catch(() => undefined)
await new Promise((resolve) => setTimeout(resolve, 2_000))
return this.ensureStarted()
}
private isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
/**
* Graceful stop only. A bound host must never be force-killed: doing so can
* leave its injected agent inside WeChat and make the next attach hang.
*/
async shutdown(): Promise<void> {
if (this.restartTimer) {
clearTimeout(this.restartTimer)
this.restartTimer = null
}
if (this.lifecycle === 'stopped' && this.child === null) return
const reachable = await this.ping()
if (!reachable && this.child === null) {
this.lifecycle = 'stopped'
this.lastBinding = null
return
}
await this.refreshBinding().catch(() => null)
const child = this.child
const hostPid = this.lastBinding?.hostPid ?? 0
this.lifecycle = 'stopped'
this.lastError = null
const response = await this.request('POST', '/shutdown', 5_000)
if (response.status !== 200) {
this.lifecycle = 'online'
this.lastError = 'Native Runtime 未确认退出请求,已保留 host 以避免微信残留会话。'
throw new Error(this.lastError)
}
const deadline = Date.now() + 55_000
let transportClosedAt = 0
while (Date.now() < deadline) {
const childExited = child !== null && child.exitCode !== null
const pidExited = hostPid > 0 && !this.isProcessAlive(hostPid)
if (childExited || pidExited) {
this.child = null
this.lastBinding = null
return
}
if (child === null && hostPid === 0) {
const transportOpen = await this.ping()
if (!transportOpen) {
if (transportClosedAt === 0) transportClosedAt = Date.now()
/* Compatibility with a pre-hostPid runtime during one upgrade:
* its teardown waits at most 45s after closing the listen socket. */
if (Date.now() - transportClosedAt >= 46_000) {
this.lastBinding = null
return
}
} else {
transportClosedAt = 0
}
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
this.lastError = 'Native Runtime 安全退出超时;未强制结束绑定态 host,请先退出微信后再重试。'
throw new Error(this.lastError)
}
}
export const macWechatRuntimeManager = new MacWechatRuntimeManager()
@@ -1,441 +0,0 @@
import { decompress as zstdDecompress } from 'fzstd'
import type { Wcdb4Client, Wcdb4Message, Wcdb4MonitorEvent } from '../wcdb4-client'
/**
* MessageListener —— 实时消息回读底座。
*
* **职责边界(刻意很窄)**:
*
* ```
* WCDB native change
* ↓
* coalesce
* ↓
* bounded DB readback
* ↓
* dedup
* ↓
* NormalizedIncomingMessage → onMessage(callback)
* ```
*
* **不负责**(这些属于下一层,本模块不许碰):关键词匹配、@我业务判断、日报、
* 自动回复、AI 调用、Agent 调度、发送消息。
*
* 设计约束:一次写入会触发**一连串** native event(十几条),
* 所以**绝不能**一个事件触发一次业务动作。
*/
/** zstd 帧魔数(微信 `source` 列是 zstd 压缩)。 */
const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
/**
* coalesce 窗口。
*
* 实测同一批 native event 会**同时**到达(同一毫秒内十几个),所以只需要一个极短的
* 合并窗口就能把它们收敛成一次回读。取 120ms:
* - 足够吃掉同一批事件(实测事件间隔 < 5ms);
* - 相对「event → 可读」本身就有 ~1.2s 的落库延迟,这点等待**不构成额外延迟**;
* - 与项目既有 `wcdb-change` 消费端的 350ms debounce 相比更短,不会叠加成明显卡顿。
*/
const DEFAULT_COALESCE_MS = 120
/** 回读窗口:只看最近这么久,绝不扫历史。 */
const DEFAULT_LOOKBACK_SEC = 30
/** 未来容忍(时钟漂移 + 秒级取整)。 */
const DEFAULT_LOOKAHEAD_SEC = 2
/** 单次回读条数上限。 */
const DEFAULT_READ_LIMIT = 50
/** dedup 条目存活时间:超过它就可以被淘汰(同一条消息不会在窗口外再被读到)。 */
const DEFAULT_DEDUP_TTL_MS = 5 * 60 * 1000
/** dedup 容量上限,防止长时间运行后无限增长。 */
const DEFAULT_DEDUP_MAX_ENTRIES = 5_000
/**
* 规范化后的入站消息。
*
* 刻意**克制**:只包含 Trigger 后续真正需要的字段,不做「万能 Message DTO」。
* 原始行(`raw`)、未解压的 Buffer、头像等一律不往外传。
*/
export interface NormalizedIncomingMessage {
sessionId: string
/** 会话内序号。**单独不保证跨 shard 唯一**,去重必须带上 sessionId。 */
localId: string
/** 服务器侧全局标识。**可能缺失**,所以只作强标识、不作必需字段。 */
serverId?: string
/**
* Unix **epoch 秒** —— 与 WCDB `create_time` / `getMessages` 参数同口径。
*
* ⚠️ 本项目另有模块使用 epoch 毫秒。**换算只允许集中在本层**:
* 凡是需要毫秒的消费者,自己明确 `/1000` 的**唯一**位置就是这里之后的调用点,
* 不许在任意函数里散落 `* 1000` / `/ 1000`。
*/
createTime: number
messageType: number
/** `mesDes === 0` 表示自己发送(字段语义与直觉相反)。 */
isSelf: boolean
senderId?: string
senderNickname?: string
content?: string
/** **已解压**的 source XML;原始 zstd Buffer 不往外传。 */
source?: string
isGroup: boolean
/**
* 从 `source` 解析出的 @ 目标(**微信 username,不是昵称**)。
*
* 本轮只产出数据,**不做「是否 @ 我」的业务判断** —— 那需要与
* `getMyUsernameCandidates()` 求交集,属于下一层。
*/
mentionTargets: string[]
}
/** 监听统计(不含任何身份信息,可安全记录)。 */
export interface MessageListenerStats {
/** 收到的 native change event 数。 */
nativeEvents: number
/** 被 coalesce 合并掉的事件数(未单独触发回读)。 */
coalescedEvents: number
/** 实际执行的回读次数。 */
readbacks: number
/** 通过 dedup 并投递出去的消息数。 */
delivered: number
/** 因 dedup 被丢弃的重复投递数。 */
deduped: number
}
export interface MessageListenerOptions {
coalesceMs?: number
lookbackSec?: number
lookaheadSec?: number
readLimit?: number
dedupTtlMs?: number
dedupMaxEntries?: number
}
/**
* 从 `source`(**已解压的 XML 文本**)提取 @ 目标。
*
* 纯函数:不依赖任何运行时状态,便于单测。
*
* 实测两种形式都真实存在,**都必须支持**:
* ```xml
* <atuserlist><![CDATA[SELF_USERNAME]]></atuserlist>
* <atuserlist>SELF_USERNAME</atuserlist>
* ```
*
* ⚠️ 三条硬规则:
* 1. **CDATA 外壳必须剥掉**,否则拿到的是 `<![CDATA[xxx]]>` 字面量;
* 2. **值就是微信 username,不保证以 `wxid_` 开头** —— 绝不能用前缀做过滤/校验;
* 3. **不允许多人分隔符的猜测** —— 多人 @ 的格式尚未在真实样本中验证过,
* 这里按「单个值原样返回」处理;一旦拿到真实多人样本需补解析与测试
* (当前形状已经预留为 `string[]`)。
*/
export function extractMentionTargets(source: string | undefined): string[] {
const text = String(source ?? '')
if (!text) return []
const match = /<atuserlist>([\s\S]*?)<\/atuserlist>/i.exec(text)
if (!match) return []
const inner = match[1].trim()
const unwrapped = inner.replace(/^<!\[CDATA\[/, '').replace(/\]\]>$/, '').trim()
return unwrapped ? [unwrapped] : []
}
/** 把 WCDB 的 `source` 列(zstd 压缩的 Buffer)还原成 XML 文本。 */
export function decodeSourcePayload(value: unknown): string | undefined {
let buffer: Buffer | null = null
if (Buffer.isBuffer(value)) {
buffer = value
} else {
const candidate = value as { type?: string; data?: unknown } | null
if (candidate?.type === 'Buffer' && Array.isArray(candidate.data)) {
buffer = Buffer.from(candidate.data as number[])
}
}
if (!buffer || buffer.length === 0) return undefined
const isZstd = ZSTD_MAGIC.every((byte, index) => buffer![index] === byte)
if (!isZstd) return buffer.toString('utf8')
try {
return Buffer.from(zstdDecompress(buffer)).toString('utf8')
} catch {
return undefined
}
}
/**
* 实时消息监听。
*
* ⚠️ **已知 BLOCKER(必须如实标注)**:
* 本实现**无法从 native event 判断是哪个会话发生了变化** —— 事件 payload 只有
* `{db, table, action}`(native 侧 `MonitorEvent` 结构就这两个字段)。
* 因此当前策略是**回读「最近活跃会话」**(`sessions[0]`,按 `last_timestamp` 排序),
* 这在「刚刚收到消息的会话」这一场景下成立,但:
*
* - **不是**「全局监听所有微信会话」;
* - 若同一窗口内有**多个会话**同时来消息,只会回读到其中最近的那个;
* - 正式扩展需要解决「如何从 table event 推断需要回读哪些会话」。
*
* 详见 `NEXT_STEPS` 注释与 Spike 报告。
*/
export class MessageListenerService {
private readonly listeners = new Set<(message: NormalizedIncomingMessage) => void>()
/**
* dedup 缓存:`sessionId:localId` → 首次见到的时间戳。
*
* 必须有界 —— Spike 里用的是无上限 `Set`,长时间运行会持续吃内存。
*/
private readonly seen = new Map<string, number>()
/**
* 本批 coalesce 窗口内**出现过精确会话**的事件收集到的 session 集合。
*
* v2 事件(Native Monitor Event v2)会带上发生变化的 sessionId,这里用 **Set** 收集 ——
* 120ms 内收到 `A B A C B` 必须回读 A/B/C 三个,绝不能「最后一个 wins」。
*/
private readonly pendingSessions = new Set<string>()
private coalesceTimer: ReturnType<typeof setTimeout> | null = null
private readbackInFlight = false
private disposed = false
private readonly coalesceMs: number
private readonly lookbackSec: number
private readonly lookaheadSec: number
private readonly readLimit: number
private readonly dedupTtlMs: number
private readonly dedupMaxEntries: number
private counters: MessageListenerStats = {
nativeEvents: 0,
coalescedEvents: 0,
readbacks: 0,
delivered: 0,
deduped: 0
}
constructor(
private readonly client: Wcdb4Client,
options: MessageListenerOptions = {}
) {
this.coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS
this.lookbackSec = options.lookbackSec ?? DEFAULT_LOOKBACK_SEC
this.lookaheadSec = options.lookaheadSec ?? DEFAULT_LOOKAHEAD_SEC
this.readLimit = options.readLimit ?? DEFAULT_READ_LIMIT
this.dedupTtlMs = options.dedupTtlMs ?? DEFAULT_DEDUP_TTL_MS
this.dedupMaxEntries = options.dedupMaxEntries ?? DEFAULT_DEDUP_MAX_ENTRIES
}
/**
* 订阅新消息。返回取消订阅函数。
*
* 刻意**只提供回调**:当前规模不需要 EventEmitter / RxJS / 内部队列。
*/
onMessage(listener: (message: NormalizedIncomingMessage) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
stats(): MessageListenerStats {
return { ...this.counters }
}
dispose(): void {
this.disposed = true
if (this.coalesceTimer) {
clearTimeout(this.coalesceTimer)
this.coalesceTimer = null
}
this.listeners.clear()
this.seen.clear()
}
/**
* 接 native change event。
*
* 只做计数、收集会话与 coalesce —— **绝不**在这里回读,更不触发任何业务。
*
* `event.protocol === 2` 时把 `sessionId` 收进本批的集合;legacy 事件(v1,不带会话)
* 走到回读时仍然只能退化成「最近活跃会话」。
*/
handleNativeChange(event?: Wcdb4MonitorEvent): void {
if (this.disposed) return
this.counters.nativeEvents += 1
if (event?.protocol === 2 && event.sessionId) {
this.pendingSessions.add(event.sessionId)
}
if (this.coalesceTimer) {
// 已经在同一个窗口里:直接合并掉,这就是 17:1 那个比值的收敛点。
this.counters.coalescedEvents += 1
return
}
this.coalesceTimer = setTimeout(() => {
this.coalesceTimer = null
void this.readback()
}, this.coalesceMs)
}
/**
* 有界回读 → normalize → dedup → 投递。
*
* 回读范围严格受限:**按会话** + **小时间窗口** + **条数上限**。不遍历会话、不扫全库。
*
* 目标会话的来源有两种:
* - **precise(protocol v2)**:本批事件收集到的 `sessionId` 集合,逐个回读
* (多个会话同时来消息时,A/B/C 都会被读到);
* - **legacy(protocol v1,旧 runtime)**:只能退回「最近活跃会话」`getSessions()[0]`。
*/
private async readback(): Promise<void> {
if (this.disposed || this.readbackInFlight) return
this.readbackInFlight = true
const startedAt = Date.now()
const preciseSessions = Array.from(this.pendingSessions)
this.pendingSessions.clear()
try {
let delivered = 0
let sessions = 0
if (preciseSessions.length > 0) {
for (const sessionId of preciseSessions) {
if (this.disposed) return
delivered += await this.readbackSession(sessionId)
sessions += 1
}
} else {
// legacy:旧 runtime 的 payload 不带会话,只能回读「最近活跃会话」。
const session = this.client.getSessions()[0]
if (!session?.username) return
delivered = await this.readbackSession(session.username)
sessions = 1
}
if (delivered > 0) {
// 日志只记录协议版本、数量与耗时 —— **不含** wxid / 群名 / 昵称 / 正文 / source / sessionId。
console.log(
`[MessageListener] protocol=${preciseSessions.length > 0 ? 'v2' : 'v1'}` +
` sessions=${sessions} delivered=${delivered} readbackMs=${Date.now() - startedAt}` +
` events=${this.counters.nativeEvents} coalesced=${this.counters.coalescedEvents}`
)
}
} catch (error) {
console.warn(
`[MessageListener] readback failed: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
this.readbackInFlight = false
}
}
/** 回读**单个**会话并投递。返回本次真正投递出去的条数。 */
private async readbackSession(sessionId: string): Promise<number> {
const nowSec = Math.floor(Date.now() / 1000)
this.counters.readbacks += 1
const messages = await this.client.getMessagesAsync(
sessionId,
nowSec - this.lookbackSec,
nowSec + this.lookaheadSec,
{ limit: this.readLimit }
)
if (this.disposed) return 0
let delivered = 0
for (const message of messages) {
const normalized = this.normalize(sessionId, message)
if (!normalized) continue
// dedup:同一条消息可能在多个读回窗口中反复出现(native 事件本身也可能重复)。
if (!this.markSeen(normalized)) {
this.counters.deduped += 1
continue
}
this.counters.delivered += 1
delivered += 1
this.deliver(normalized)
}
return delivered
}
private deliver(message: NormalizedIncomingMessage): void {
for (const listener of this.listeners) {
try {
listener(message)
} catch (error) {
console.warn(
`[MessageListener] listener threw: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
}
/** 把 WCDB 行规范化为克制的 DTO。返回 null 表示这条行缺关键标识,无法投递。 */
private normalize(
sessionUsername: string,
message: Wcdb4Message
): NormalizedIncomingMessage | null {
const localId = message.mesLocalID === undefined ? '' : String(message.mesLocalID)
if (!localId) return null
const createTime = Number(message.msgCreateTime) || 0
if (!createTime) return null
const raw = (message.raw ?? {}) as Record<string, unknown>
const source = decodeSourcePayload(raw.source)
const senderId = message.sender ? String(message.sender) : undefined
return {
sessionId: sessionUsername,
localId,
serverId: message.serverId ? String(message.serverId) : undefined,
createTime,
messageType: Number(message.messageType) || 0,
// `mesDes === 0` 是自己发送。不用昵称 / sender 文本 / content / username 前缀判断。
isSelf: Number(message.mesDes) === 0,
senderId,
senderNickname: message.senderNickname ? String(message.senderNickname) : undefined,
content: message.msgContent ? String(message.msgContent) : undefined,
source,
isGroup: sessionUsername.endsWith('@chatroom'),
mentionTargets: extractMentionTargets(source)
}
}
/** 登记并返回「是否是第一次见到」。同时做 TTL + 容量淘汰。 */
private markSeen(message: NormalizedIncomingMessage): boolean {
const key = `${message.sessionId}:${message.localId}`
const now = Date.now()
if (this.seen.has(key)) return false
this.seen.set(key, now)
if (this.seen.size > this.dedupMaxEntries) this.evict(now)
return true
}
/**
* 淘汰策略:先按 TTL 清理过期条目;若仍然超限,再按插入顺序丢掉最旧的一批
* (Map 保持插入顺序,所以从头删就是删最旧)。
*/
private evict(now: number): void {
for (const [key, seenAt] of this.seen) {
if (now - seenAt > this.dedupTtlMs) this.seen.delete(key)
}
if (this.seen.size <= this.dedupMaxEntries) return
const overflow = this.seen.size - this.dedupMaxEntries
let removed = 0
for (const key of this.seen.keys()) {
this.seen.delete(key)
removed += 1
if (removed >= overflow) break
}
}
}
/**
* NEXT STEPS(不在本轮范围)
*
* 1. **会话定位(BLOCKER)**:目前只能回读「最近活跃会话」。要真正监听全部会话,
* 需要解决「从 table event 推断变更会话」。可能的路线:
* - 观察 native 侧能否在事件里补上 table 所属的会话标识(需改 dll);
* - 或改成按 `Session` 表的 `last_timestamp` 变化做「有界的多会话回读」。
* 2. **多人 @**:`extractMentionTargets` 目前按单值返回,多人格式**未验证**,不得猜分隔符。
* 3. **isMentionedMe**:`mentionTargets` ∩ `getMyUsernameCandidates()`,再接 Trigger。
* 4. 本轮**不做**:关键词 / @触发 / 自动回复 / 日报 / AI / Agent / 发送。
*/
@@ -7,28 +7,15 @@ import {
personalWechatSendService,
type PersonalWechatSendService
} from './personal-wechat-send-service'
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
/**
* Converts the detailed sender diagnostics into a small contract that other
* features can consume without knowing about the macOS native runtime, the
* Windows hook transport, or platform details.
*
* On macOS the capability is derived only from MacWechatRuntimeManager and its
* validated runtime manifest. Windows continues to use the existing sender.
* features can consume without knowing about OneBot, Hook or platform details.
*/
export class PersonalWechatCapabilityService {
constructor(
private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>,
private readonly platform: NodeJS.Platform = process.platform,
private readonly getMacStatus: () => Promise<PersonalWechatSenderStatus> = () =>
macWechatRuntimeManager.buildSenderStatus()
) {}
constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {}
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
if (this.platform === 'darwin') {
return this.fromMacRuntime()
}
const senderStatus = await this.sender.getStatus()
return this.fromSenderStatus(senderStatus)
}
@@ -62,16 +49,6 @@ export class PersonalWechatCapabilityService {
}
}
/*
* macOS capability from the native runtime. Builds a senderStatus snapshot
* aligned with the mac binding state and reuses the shared mapping, so the
* settings header and the binding card can never disagree.
*/
private async fromMacRuntime(): Promise<PersonalWechatSendCapability> {
const senderStatus = await this.getMacStatus()
return this.fromSenderStatus(senderStatus)
}
private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState {
if (senderStatus.platform === 'win32') {
if (senderStatus.canSend) return 'ready'
@@ -83,7 +60,6 @@ export class PersonalWechatCapabilityService {
return 'unsupported'
}
if (senderStatus.state === 'error') return 'error'
if (senderStatus.state === 'runtime_missing') return 'unconfigured'
const hasCurrentBinding = Boolean(
senderStatus.endpointReady &&
senderStatus.attachReady &&
@@ -0,0 +1,603 @@
import { createHash } from 'crypto'
import { execFile } from 'child_process'
import { app, net } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { chmod, copyFile, cp, mkdir, mkdtemp, open, rename, rm, stat } from 'fs/promises'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { promisify } from 'util'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeStatus
} from '../../shared/personal-wechat-runtime'
import { findPersonalWechatRuntime } from './personal-wechat-send-service'
const execFileAsync = promisify(execFile)
const RUNTIME_VERSION = 'v0.0.18'
const ARCHIVE_NAME = 'onebot_mac_arm64.tar.gz'
const ARCHIVE_URL = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${RUNTIME_VERSION}/${ARCHIVE_NAME}`
const ARCHIVE_SIZE = 66_599_785
const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e3409143e107dab'
function patchPerSendPayload(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
activeTriggerX1Payload = Memory.alloc(payloadData.length);
activeTriggerX1Payload.writeByteArray(payloadData);
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
triggerX1Payload.writeByteArray(payloadData);
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
if (!source.includes(activeSend)) {
throw new Error('下载的发送组件与当前应用不兼容')
}
source = source.replace('var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;', 'var triggerX1Payload;\nvar triggerX0;')
.replace(activeSend, upstreamSend)
.replace(' MMStartTask(triggerX0, activeTriggerX1Payload);', ' MMStartTask(triggerX0, triggerX1Payload);')
.replace(' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', ' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);')
.replace('\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn', '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn')
writeFileSync(scriptPath, source)
}
function patchSendContextCapture(scriptPath: string, strict = true): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('function isLikelySendContext(')) return
const original = `function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) {
return
}
triggerX0 = this.context.x0;
triggerX1Payload = this.context.x1;
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
try {
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
var manager = readPointerIfReadable(candidateX0.add(0x18));
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
return !manager.equals(ptr(0));
} catch (e) {
console.error("[debug] StartTask candidate inspect failed: " + e);
return false;
}
}
function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) return;
var candidateX0 = this.context.x0;
var candidateX1 = this.context.x1;
if (!isLikelySendContext(candidateX0, candidateX1)) return;
triggerX0 = candidateX0;
triggerX1Payload = candidateX1;
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
if (!source.includes(original)) {
if (strict) throw new Error('下载的微信版本与当前应用不兼容')
return
}
source = source.replace(original, patched)
writeFileSync(scriptPath, source)
}
function patchVoiceAudioBuffer(scriptPath: string, strict = true): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
if (!source.includes(staticAllocation)) {
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
return
}
source = source.replace(
staticAllocation,
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
)
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
return
}
source = source.replace(
audioLengthMarker,
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
writeFileSync(scriptPath, source)
}
function patchImageHookReadiness(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
source.includes('图片上传 Hook Setup Complete')
) {
return
}
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
\t\t\t}
\t\t\tuploadGlobalX0 = capturedUploadX0;`
if (!source.includes(original)) throw new Error('下载的媒体组件与当前应用不兼容')
source = source.replace(original, patched)
source = source.replace(
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
)
writeFileSync(scriptPath, source)
}
function patchCdnColdStart(scriptPath: string, strict = true): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('function resolveCdnManager()')) return
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
if (!source.includes(initAddresses)) {
if (strict) throw new Error('下载的微信版本配置与当前应用不兼容')
return
}
source = source.replace(initAddresses, patchedInitAddresses)
const downloadChunkEnd = `}
function fillUploadX1AndStart`
const resolver = `}
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
function resolveCdnManager() {
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
return ptr(0);
}
try {
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
var strDefault = Memory.alloc(24);
strDefault.writeUtf8String("default");
strDefault.add(0x17).writeU8(7);
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
var svc = getService(strDefault);
if (!isReadablePointer(svc)) {
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
return ptr(0);
}
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
var ctx = getCtx(svc);
if (!isReadablePointer(ctx)) {
console.error("[!] CdnManager getter 返回不可读: " + ctx);
return ptr(0);
}
var mgr = readPointerIfReadable(ctx.add(0x40));
if (!isReadablePointer(mgr)) {
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
return ptr(0);
}
return mgr;
} catch (e) {
console.error("[!] resolveCdnManager 异常: " + e);
return ptr(0);
}
}
function ensureCdnManagerX0() {
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
}
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = uploadGlobalX0;
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
}
if (uploadGlobalX0.equals(ptr(0))) {
var mgr = resolveCdnManager();
if (!mgr.equals(ptr(0))) {
uploadGlobalX0 = mgr;
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = mgr;
}
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
}
}
return !uploadGlobalX0.equals(ptr(0));
}
function fillUploadX1AndStart`
if (!source.includes(downloadChunkEnd)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
return
}
source = source.replace(downloadChunkEnd, resolver)
const declarations = 'var uploadImageAddr;\n'
const patchedDeclarations =
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
if (!source.includes(declarations)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体地址声明')
return
}
source = source.replace(declarations, patchedDeclarations)
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(uploadGuard)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传入口')
return
}
source = source.replace(uploadGuard, patchedUploadGuard)
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(voiceGuard)) {
if (strict) throw new Error('无法定位 wechat_chatter 语音上传入口')
return
}
source = source.replace(voiceGuard, patchedVoiceGuard)
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
downloadGlobalX0 = capturedUploadX0;
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
}`
if (!source.includes(uploadHook)) {
if (strict) throw new Error('无法定位 wechat_chatter 图片 Hook')
return
}
source = source.replace(uploadHook, patchedUploadHook)
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
}`
if (!source.includes(downloadHook)) {
if (strict) throw new Error('无法定位 wechat_chatter 下载 Hook')
return
}
source = source.replace(downloadHook, patchedDownloadHook)
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0) {`
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (!downloadGlobalX0) {`
if (!source.includes(downloadGuard)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体下载入口')
return
}
source = source.replace(downloadGuard, patchedDownloadGuard)
writeFileSync(scriptPath, source)
}
function patchCdnColdStartConfig(configPath: string, strict = true): void {
let config: Record<string, unknown>
try {
config = JSON.parse(readFileSync(configPath, 'utf8')) as Record<string, unknown>
} catch {
if (strict) throw new Error('4.1.11.53 版本配置不是有效 JSON')
return
}
config.cdnGetServiceAddr = '0x50a15d0'
config.cdnManagerGetterAddr = '0x5259290'
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
function patchWechatCoreModuleBase(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('WeChat core module base:')) return
const initMarker = 'function initAddresses() {'
const initIndex = source.indexOf(initMarker)
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
throw new Error('下载的微信版本配置与当前应用不兼容')
}
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
var module = Process.enumerateModules().find(function(m) {
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
});
if (!module) {
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
}
var moduleBase = module.base;
var baseAddr = moduleBase;
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
setImmediate(initAddresses);
`
source = patchedHeader + source.slice(initIndex)
writeFileSync(scriptPath, source)
}
function addModifiedWorkNotice(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
source = `/*
* TraceMemo wechat_chatter compatibility modifications
* Modified: 2026-08-17
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
* These modifications are not provided by the upstream author.
*/
${source}`
writeFileSync(scriptPath, source)
}
export class PersonalWechatRuntimeManager {
private downloadController: AbortController | null = null
private downloadPromise: Promise<PersonalWechatRuntimeDownloadResult> | null = null
private downloadedBytes = 0
private lastProgressAt = 0
private progressListener: ((status: PersonalWechatRuntimeStatus) => void) | null = null
get directory(): string {
return join(app.getPath('userData'), 'connectors', 'wechat-personal', 'darwin-arm64')
}
private get archivePath(): string {
return join(
app.getPath('userData'),
'downloads',
`wechat-chatter-${RUNTIME_VERSION}-${ARCHIVE_NAME}`
)
}
setProgressListener(listener: ((status: PersonalWechatRuntimeStatus) => void) | null): void {
this.progressListener = listener
}
async getStatus(): Promise<PersonalWechatRuntimeStatus> {
if (!this.isSupported()) {
return this.buildStatus(
'unsupported',
0,
process.platform === 'darwin'
? 'Intel Mac 不支持个人微信发送组件'
: process.platform === 'win32'
? 'Windows 暂不支持个人微信发送组件'
: '当前系统不支持个人微信发送组件'
)
}
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
const runtime = findPersonalWechatRuntime()
if (runtime) {
try {
// Apply compatibility fixes to runtimes installed before this version.
patchWechatCoreModuleBase(join(runtime.workingDirectory, 'script.js'))
patchPerSendPayload(join(runtime.workingDirectory, 'script.js'))
patchSendContextCapture(join(runtime.workingDirectory, 'script.js'), false)
patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false)
patchImageHookReadiness(join(runtime.workingDirectory, 'script.js'))
patchCdnColdStart(join(runtime.workingDirectory, 'script.js'), false)
addModifiedWorkNotice(join(runtime.workingDirectory, 'script.js'))
patchCdnColdStartConfig(join(runtime.root, 'wechat_version', '4_1_11_53_mac.json'), false)
} catch {
// Status discovery should remain available even if an old runtime is read-only.
}
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
}
const hasPartialInstall = await this.hasPartialInstall()
return this.buildStatus(
hasPartialInstall ? 'invalid' : 'missing',
0,
hasPartialInstall ? '发送组件文件不完整,请重新下载' : undefined,
hasPartialInstall ? this.directory : undefined
)
}
download(): Promise<PersonalWechatRuntimeDownloadResult> {
if (!this.isSupported()) {
return this.getStatus().then((status) => ({ success: false, status, error: status.error }))
}
if (this.downloadPromise) return this.downloadPromise
this.downloadedBytes = 0
this.downloadController = new AbortController()
this.downloadPromise = this.runDownload(this.downloadController.signal).finally(() => {
this.downloadPromise = null
this.downloadController = null
})
return this.downloadPromise
}
cancelDownload(): boolean {
if (!this.downloadController) return false
this.downloadController.abort()
return true
}
async remove(): Promise<PersonalWechatRuntimeStatus> {
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
await Promise.all([
rm(this.directory, { recursive: true, force: true }),
rm(this.archivePath, { force: true }),
rm(`${this.archivePath}.partial`, { force: true })
])
return this.getStatus()
}
private async runDownload(signal: AbortSignal): Promise<PersonalWechatRuntimeDownloadResult> {
const archive = this.archivePath
const downloadsDirectory = dirname(archive)
const partial = `${archive}.partial`
let extractionDirectory = ''
let stagedDirectory = ''
try {
await mkdir(downloadsDirectory, { recursive: true })
await rm(partial, { force: true })
const response = await net.fetch(ARCHIVE_URL, { signal })
if (!response.ok || !response.body) {
throw new Error(`发送组件下载失败:HTTP ${response.status}`)
}
const handle = await open(partial, 'w')
const hash = createHash('sha256')
try {
const reader = response.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
if (signal.aborted) throw new DOMException('Download cancelled', 'AbortError')
const chunk = Buffer.from(value)
await handle.write(chunk)
hash.update(chunk)
this.downloadedBytes += chunk.length
this.reportProgress(this.buildStatus('downloading', this.downloadedBytes))
}
} finally {
await handle.close()
}
if (this.downloadedBytes !== ARCHIVE_SIZE || hash.digest('hex') !== ARCHIVE_SHA256) {
throw new Error('发送组件校验失败,请重新下载')
}
await rm(archive, { force: true })
await rename(partial, archive)
extractionDirectory = await mkdtemp(join(tmpdir(), 'wechat-chatter-extract-'))
await execFileAsync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDirectory])
stagedDirectory = `${this.directory}.installing-${process.pid}`
await rm(stagedDirectory, { recursive: true, force: true })
await mkdir(dirname(stagedDirectory), { recursive: true })
await mkdir(join(stagedDirectory, 'onebot'), { recursive: true })
const sourceOneBot = join(extractionDirectory, 'onebot')
const sourceVersions = join(extractionDirectory, 'wechat_version')
await Promise.all([
cp(sourceVersions, join(stagedDirectory, 'wechat_version'), {
recursive: true,
force: true
}),
copyFile(join(sourceOneBot, 'onebot'), join(stagedDirectory, 'onebot', 'onebot')),
copyFile(join(sourceOneBot, 'script.js'), join(stagedDirectory, 'onebot', 'script.js'))
])
const executable = join(stagedDirectory, 'onebot', 'onebot')
const script = join(stagedDirectory, 'onebot', 'script.js')
const configPath = join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
patchCdnColdStartConfig(configPath)
patchWechatCoreModuleBase(script)
patchPerSendPayload(script)
patchSendContextCapture(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script)
patchCdnColdStart(script)
addModifiedWorkNotice(script)
await chmod(executable, 0o755)
for (const required of [
executable,
script,
join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
]) {
if (!existsSync(required)) throw new Error('发送组件解压后文件不完整')
}
await rm(this.directory, { recursive: true, force: true })
await rename(stagedDirectory, this.directory)
stagedDirectory = ''
const status = this.buildStatus('ready', ARCHIVE_SIZE, undefined, this.directory)
this.reportProgress(status, true)
return { success: true, status }
} catch (error) {
const cancelled = signal.aborted
const message = cancelled
? '发送组件下载已取消'
: error instanceof Error
? error.message
: String(error)
await rm(partial, { force: true })
const status = this.buildStatus(
cancelled ? 'missing' : 'error',
this.downloadedBytes,
message
)
this.reportProgress(status, true)
return { success: false, status, error: message }
} finally {
if (extractionDirectory) await rm(extractionDirectory, { recursive: true, force: true })
if (stagedDirectory) await rm(stagedDirectory, { recursive: true, force: true })
}
}
private async hasPartialInstall(): Promise<boolean> {
try {
await stat(this.directory)
return true
} catch {
return false
}
}
private isSupported(): boolean {
return process.platform === 'darwin' && process.arch === 'arm64'
}
private buildStatus(
state: PersonalWechatRuntimeStatus['state'],
downloadedBytes: number,
error?: string,
directory?: string
): PersonalWechatRuntimeStatus {
return {
version: RUNTIME_VERSION,
state,
downloadedBytes,
totalBytes: ARCHIVE_SIZE,
progress: ARCHIVE_SIZE ? Math.min(1, downloadedBytes / ARCHIVE_SIZE) : 0,
platform: process.platform,
architecture: process.arch,
supported: this.isSupported(),
removable: directory === this.directory,
...(directory ? { directory } : {}),
...(error ? { error } : {})
}
}
private reportProgress(status: PersonalWechatRuntimeStatus, force = false): void {
const now = Date.now()
if (!force && now - this.lastProgressAt < 100) return
this.lastProgressAt = now
this.progressListener?.(status)
}
}
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,11 @@ import type {
PersonalWechatVoiceRuntimeComponent
} from '../../shared/personal-wechat-voice-runtime'
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
import { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service'
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
import {
buildPersonalWechatRuntimeEnvironment,
findPersonalWechatRuntime
} from './personal-wechat-send-service'
import type { RuntimeLayout } from './personal-wechat-send-service'
import { appLogger } from '../app-logger'
const execFileAsync = promisify(execFile)
@@ -34,8 +37,8 @@ type CommandRunner = (
interface PersonalWechatVoiceEnvironmentServiceOptions {
platform?: NodeJS.Platform
architecture?: string
isRuntimePresent?: () => boolean
buildEnvironment?: () => NodeJS.ProcessEnv
findRuntime?: () => RuntimeLayout | null
buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv
runCommand?: CommandRunner
now?: () => Date
}
@@ -97,26 +100,18 @@ function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment {
}
}
function defaultIsRuntimePresent(): boolean {
try {
return macWechatRuntimeManager.isRuntimePresent()
} catch {
return false
}
}
export class PersonalWechatVoiceEnvironmentService {
private readonly platform: NodeJS.Platform
private readonly architecture: string
private readonly isRuntimePresent: () => boolean
private readonly buildEnvironment: () => NodeJS.ProcessEnv
private readonly findRuntime: () => RuntimeLayout | null
private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv
private readonly runCommand: CommandRunner
private readonly now: () => Date
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
this.platform = options.platform || process.platform
this.architecture = options.architecture || process.arch
this.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent
this.findRuntime = options.findRuntime || findPersonalWechatRuntime
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
this.runCommand = options.runCommand || runCommand
this.now = options.now || (() => new Date())
@@ -128,8 +123,8 @@ export class PersonalWechatVoiceEnvironmentService {
}
logEnvironmentLine('Checking voice encoding environment')
const runtimeReady = this.isRuntimePresent()
const environment = this.buildEnvironment()
const runtime = this.findRuntime()
const environment = this.buildEnvironment(runtime?.root)
const python = blankComponent()
const pilk = blankComponent()
const ffmpeg = blankComponent()
@@ -202,25 +197,26 @@ export class PersonalWechatVoiceEnvironmentService {
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
}
const ready = Boolean(runtimeReady && python.ready && pilk.ready && ffmpeg.ready)
const ready = Boolean(runtime && python.ready && pilk.ready && ffmpeg.ready)
const result: PersonalWechatVoiceEncodingEnvironment = {
state: ready ? 'ready' : 'incomplete',
ready,
checkedAt: this.now().toISOString(),
runtimeReady,
runtimeReady: Boolean(runtime),
...(runtime ? { runtimeRoot: runtime.root } : {}),
python,
pilk,
ffmpeg,
encoder: pilk.ready
? 'pilk'
: runtimeReady && python.ready && ffmpeg.ready
? 'silk'
: runtime && python.ready && ffmpeg.ready
? 'go-silk'
: 'unavailable',
message: ready
? '语音编码环境正常,可以使用 pilk 编码'
: runtimeReady
? '语音编码环境不完整,语音将回退到内置 SILK 编码'
: '当前版本暂未提供微信消息发送功能'
: runtime
? '语音编码环境不完整,OneBot 可能回退到 go-silk'
: '微信发送组件尚未安装,请先准备 OneBot 运行时'
}
logEnvironmentLine(
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
@@ -249,7 +245,7 @@ export class PersonalWechatVoiceEnvironmentService {
pythonExecutable,
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
{
env: this.buildEnvironment(),
env: this.buildEnvironment(before.runtimeRoot || undefined),
timeout: INSTALL_TIMEOUT_MS
}
)
@@ -27,7 +27,8 @@ export interface ScheduledReportApiDependencies {
| 'deleteTask'
| 'setTaskEnabled'
| 'runScheduledReportNow'
>
> &
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
getCapability: () => Promise<PersonalWechatSendCapability>
listContacts: () => FormattedContact[]
isDatabaseReady: () => boolean
@@ -249,20 +250,16 @@ export class ScheduledReportApiService {
return result.data
}
/**
* 「重新发送」不再支持。
*
* 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG
* 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发
* 语义,不如**明确返回 501**。
* 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。
*/
async retrySend(_executionId: string): Promise<ScheduledReportExecution> {
throw new ScheduledReportApiError(
501,
'not_supported',
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
)
async retrySend(executionId: string): Promise<ScheduledReportExecution> {
const retry = this.deps.service.retryScheduledReportSend
if (!retry) {
throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报')
}
const result = await retry.call(this.deps.service, executionId)
if (!result.data) {
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
}
return result.data
}
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
@@ -1,207 +0,0 @@
import { createHash } from 'node:crypto'
import {
normalizeScheduledReportConfig,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import type {
ScheduledReportMemberNameMode,
ScheduledReportMessageType,
ScheduledReportRange
} from '../../shared/scheduled-report'
import type { SelectableReportTemplateId } from '../../shared/report-templates'
/**
* 旧「定时日报任务」→ 新「AutomationRule(ruleType='scheduled_report')」的**纯映射器**。
*
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
* 不允许只靠"跑一遍看看"来验证。
*
* ## 旧 target 的真实语义(实测)
*
* `ScheduledReportService.resolveTarget()`:
* - `target` 以 `@chatroom` 结尾 ⇒ **直接发到那个群**(可能是另一个群);
* - 否则 ⇒ **静默回落到来源群**。
*
* ⇒ 历史行为**事实上支持「生成 A 群日报 → 发 B 群」**,也存在一处静默 fallback。
* 新产品明确不支持"指定另一个群聊",所以:
* **能证明同群 → 无损映射成 `source_chat`;不能证明 → 停用 + 要求用户重选**,
* 绝不自动改写、绝不偷偷丢掉目标。
*/
/** 旧任务里迁移真正用得到的字段(其余字段不参与,也不需要读)。 */
export interface LegacyScheduledReportTask {
id?: unknown
name?: unknown
group?: unknown
scheduleTime?: unknown
reportRange?: unknown
messageTypes?: unknown
templateId?: unknown
memberNameMode?: unknown
timeoutSeconds?: unknown
target?: unknown
enabled?: unknown
createdAt?: unknown
updatedAt?: unknown
lastRunAt?: unknown
lastScheduledSlot?: unknown
}
export type ScheduledReportMigrationOutcome =
/** 目标与来源群是同一群 ⇒ 等价于 `source_chat`,行为完全一致。 */
| 'lossless_source_chat'
/** 目标是另一个群 / 无法确认是否同群 ⇒ 单值四选一表达不了,需用户重选。 */
| 'needs_review'
export interface ScheduledReportMigrationPlan {
outcome: ScheduledReportMigrationOutcome
/** 迁移后的规则 id(确定性,可重复执行且不产生新 id)。 */
ruleId: string
name: string
enabled: boolean
config: ScheduledReportAutomationConfig
createdAt: number
updatedAt: number
}
/** 把任意输入收敛成字符串。 */
function asText(value: unknown): string {
return typeof value === 'string' ? value.trim() : String(value ?? '').trim()
}
/**
* 确定性 ruleId。
*
* 优先**原样复用旧 taskId**(旧 id 形如 `scheduled_report_<uuid>`,与自动化现有
* id 空间 —— `randomUUID()` / `builtin-*` —— 不可能冲突)。
* 旧 id 缺失时(文件被手改坏),退化成一个**由稳定字段派生**的 id,
* 保证"重复迁移不会生成新规则"。
*/
export function scheduledReportMigrationRuleId(task: LegacyScheduledReportTask): string {
const id = asText(task.id)
if (id) return id
const digest = createHash('sha1')
.update(
[asText(task.name), asText(task.group), asText(task.scheduleTime), asText(task.target)].join(
'\u0001'
)
)
.digest('hex')
return `scheduled-report:${digest.slice(0, 32)}`
}
/** 旧 `createdAt` / `updatedAt` 是 ISO 字符串;非法时回落到迁移时刻。 */
function parseIso(value: unknown, fallback: number): number {
const text = asText(value)
if (!text) return fallback
const parsed = Date.parse(text)
return Number.isFinite(parsed) ? parsed : fallback
}
/**
* 单条旧任务 → 迁移计划。
*
* `resolveConversationId` 由调用方注入(main 侧是 `chat-service.resolveMd5`,
* 单测里是假的联系人表)。**它拿不到就说明主进程还没准备好**,
* 调用方必须**推迟整批迁移**,而不是用"解析不到"去误判成 needs_review。
*/
export function planScheduledReportMigration(
task: LegacyScheduledReportTask,
resolveConversationId: (raw: string) => string | undefined,
now: number
): ScheduledReportMigrationPlan {
const sourceRaw = asText(task.group)
const targetRaw = asText(task.target)
const sourceResolved = resolveConversationId(sourceRaw)
const targetResolved = resolveConversationId(targetRaw)
/*
* 三种"能证明是同一个群"的情况:
* 1. target 为空 —— 旧 `normalizeInput` 本就把空 target 填成 group;
* 2. 两者文本完全相等 —— 无论能否解析,用户表达的就是同一个东西;
* 3. 两者都能解析且解析结果相同 —— 旧 UI 的常态(group=会话 md5,target=roomId)。
*/
const sameGroup =
!targetRaw || targetRaw === sourceRaw || Boolean(sourceResolved && targetResolved && sourceResolved === targetResolved)
const config = normalizeScheduledReportConfig({
schedule: { time: asText(task.scheduleTime) },
report: {
// 解析得到就用稳定会话 id;解析不到**保留原值** —— 运行期会如实失败,
// 绝不在这里"猜一个群"。
sourceConversationId: sourceResolved ?? sourceRaw,
range: asText(task.reportRange) as ScheduledReportRange,
messageTypes: task.messageTypes as ScheduledReportMessageType[],
templateId: asText(task.templateId) as SelectableReportTemplateId,
memberNameMode: asText(task.memberNameMode) as ScheduledReportMemberNameMode,
timeoutSeconds: Number(task.timeoutSeconds)
},
target: { type: 'source_chat' },
...(sameGroup ? {} : { targetNeedsReview: true }),
// 旧目标原文只作展示;`sameGroup` 时不保留,避免留下看起来像"还生效"的残值。
...(sameGroup || !targetRaw ? {} : { legacyTarget: targetRaw }),
// 来源群被改写过(md5/群名 → roomId)时留下原文,便于用户核对。
...(sourceRaw && sourceResolved && sourceRaw !== sourceResolved
? { legacySourceGroup: sourceRaw }
: {}),
...(asText(task.lastRunAt) ? { lastRunAt: asText(task.lastRunAt) } : {}),
...(asText(task.lastScheduledSlot) ? { lastScheduledSlot: asText(task.lastScheduledSlot) } : {})
})
const name = asText(task.name) || '定时日报'
return {
outcome: sameGroup ? 'lossless_source_chat' : 'needs_review',
ruleId: scheduledReportMigrationRuleId(task),
name,
// 不自动改写、也不自动启用:无法确认目标的规则一律停用。
enabled: sameGroup ? task.enabled !== false : false,
config,
createdAt: parseIso(task.createdAt, now),
updatedAt: parseIso(task.updatedAt, now)
}
}
export interface ScheduledReportMigrationSummary {
total: number
migrated: number
lossless: number
needsReview: number
duplicatesSkipped: number
}
/**
* 整批任务的迁移结果:**按 id 去重**,保证幂等(同一份旧文件跑两次结果一致)。
*/
export function planScheduledReportMigrationBatch(
tasks: LegacyScheduledReportTask[],
resolveConversationId: (raw: string) => string | undefined,
now: number
): { plans: ScheduledReportMigrationPlan[]; summary: ScheduledReportMigrationSummary } {
const plans: ScheduledReportMigrationPlan[] = []
const seen = new Set<string>()
let duplicatesSkipped = 0
let lossless = 0
let needsReview = 0
for (const task of tasks) {
if (!task || typeof task !== 'object') continue
const plan = planScheduledReportMigration(task, resolveConversationId, now)
if (seen.has(plan.ruleId)) {
duplicatesSkipped += 1
continue
}
seen.add(plan.ruleId)
plans.push(plan)
if (plan.outcome === 'lossless_source_chat') lossless += 1
else needsReview += 1
}
return {
plans,
summary: {
total: plans.length + duplicatesSkipped,
migrated: plans.length,
lossless,
needsReview,
duplicatesSkipped
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,81 +0,0 @@
import {
SCHEDULED_REPORT_TARGET_OPTIONS,
scheduledReportTargetLabel,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import {
resolveAutomationTarget,
resolveGroupConversationId,
resolveGroupDisplayName,
type AutomationTargetContact,
type AutomationTargetResolution
} from './automation-wechat-target'
/**
* 定时日报的**目标解析**(薄适配层)。
*
* 判定逻辑在 `automation-wechat-target.ts`,与退群通知**共用同一份**。
* 差异只有一处:来源会话不是"事件所在群",而是**这条规则配置的日报来源群**。
*/
/** 定时日报的目标文案(唯一一份)。 */
export const SCHEDULED_REPORT_TARGET_MESSAGES = {
options: SCHEDULED_REPORT_TARGET_OPTIONS,
invalidTarget: '定时日报的发送目标无效,请重新选择',
sourceMissing: '无法确定日报来源群,本次日报未发送',
sourceRecipientName: '日报来源群',
sourceDisplayName: '日报来源群',
selfMissing: '无法确定当前登录的微信账号,本次日报未发送',
contactNotChosen: '还没有选择发送联系人,请重新选择',
contactUnavailable: '发送联系人已不存在或当前无法发送,请重新选择'
} as const
export function resolveScheduledReportTarget(input: {
config: ScheduledReportAutomationConfig | undefined
contacts: AutomationTargetContact[]
/** 当前登录账号的 wxid;拿不到时传空串。 */
selfWxid?: string
}): AutomationTargetResolution {
const config = input.config
if (!config) return { ok: false, error: '定时日报尚未配置发送目标' }
const sourceConversationId = String(config.report?.sourceConversationId || '').trim()
return resolveAutomationTarget(
{
targetType: config.target?.type as never,
sourceConversationId,
// 来源群的可读名从通讯录解析;解析不到时由中性层给兜底称呼。
sourceDisplayName: resolveGroupDisplayName(sourceConversationId, input.contacts),
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
contacts: input.contacts,
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
},
SCHEDULED_REPORT_TARGET_MESSAGES
)
}
/**
* 日报来源群的可读显示名(执行日志的 `sourceDisplayName` 用它)。
*
* **绝不回落到裸 id**:解析不到就说「日报来源群」。
*/
export function scheduledReportSourceDisplayName(
config: ScheduledReportAutomationConfig | undefined,
contacts: AutomationTargetContact[]
): string {
const raw = String(config?.report?.sourceConversationId || '').trim()
if (!raw) return '日报来源群'
return resolveGroupDisplayName(raw, contacts) || '日报来源群'
}
/** 日报来源群解析成稳定会话 id(执行前预检用;解析不到返回 undefined)。 */
export function scheduledReportSourceConversationId(
config: ScheduledReportAutomationConfig | undefined,
contacts: AutomationTargetContact[]
): string | undefined {
return resolveGroupConversationId(
String(config?.report?.sourceConversationId || ''),
contacts
)
}
export { scheduledReportTargetLabel }
+6 -12
View File
@@ -39,8 +39,9 @@ export interface AppSettings {
showStartupProgress: boolean
ttsSelectedVoiceId: string
ttsModel: TextToSpeechModel
/** Keep a running personal-WeChat OneBot process across app restarts. */
keepPersonalWechatProcess?: boolean
windowsWechatPort: string
reportImagePostfixText: string
/**
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
@@ -129,8 +130,8 @@ const DEFAULT_SETTINGS: AppSettings = {
showStartupProgress: true,
ttsSelectedVoiceId: '',
ttsModel: 's2.1-pro-free',
keepPersonalWechatProcess: false,
windowsWechatPort: '',
reportImagePostfixText: '今日日报',
queryAgentEnabled: true
}
@@ -149,10 +150,7 @@ export function loadSettings(): AppSettings {
if (cache) return cache
try {
if (fs.existsSync(SETTINGS_FILE)) {
const stored = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings> & {
keepPersonalWechatProcess?: unknown
}
const { keepPersonalWechatProcess: retiredKeepProcess, ...raw } = stored
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
cache = { ...DEFAULT_SETTINGS, ...raw }
if (raw.autoLogin === undefined) {
const hasSavedDatabaseKey = fs.existsSync(
@@ -178,7 +176,7 @@ export function loadSettings(): AppSettings {
}
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) {
if (cache.recallProtectionEnabled) {
cache.recallProtectionEnabled = false
saveSettings(cache)
}
@@ -194,11 +192,7 @@ export function loadSettings(): AppSettings {
export function saveSettings(next: AppSettings): AppSettings {
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
// 避免遗留入口或旧版本把它重新打开。
const sanitized = { ...next } as AppSettings & {
keepPersonalWechatProcess?: unknown
}
delete sanitized.keepPersonalWechatProcess
cache = { ...sanitized, recallProtectionEnabled: false }
cache = { ...next, recallProtectionEnabled: false }
try {
ensureDir()
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
+80 -85
View File
@@ -5,14 +5,14 @@ import path from 'path'
import type {
PersonalWechatSendCapability,
PersonalWechatSendRequest,
PersonalWechatSendResult,
PersonalWechatSenderStatus
PersonalWechatSendResult
} from '../../shared/personal-wechat'
import type {
PolicyDecision,
WechatActionAuditRecord,
WechatActionContent,
WechatActionErrorCode,
WechatActionMemberEventReference,
WechatActionRequest,
WechatActionResult
} from '../../shared/wechat-action'
@@ -22,40 +22,17 @@ import { wechatSendGateway } from './wechat-send-gateway'
const MAX_AUDIT_RECORDS = 500
const MAX_CONTENT_PREVIEW_LENGTH = 240
export const AUTOMATION_SEND_INTERVAL_MS = 3_000
const AUTOMATION_PURPOSE_ALLOWLIST = new Set([
'scheduled_report',
// Automation(@我生成日报)。必须与 `src/shared/automation.ts` 的
// `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy
// 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。
'automation_reply',
'automation_report',
// 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友,
// 所以**不绑定**任何 "只能发回原群" 的作用域锁。
'automation_leave_notification',
// 定时日报(由 Automation 的 `scheduled_report` 规则发出)。目标是四选一,
// 同样**不绑定**任何作用域锁。
'automation_scheduled_report',
// 定时日报的「后置词」(图片 sent 之后补发的那条文本)。与图片是两个独立
// purpose,各自有幂等位 —— 少这一条会被 evaluateWechatActionPolicy 拦下。
'automation_scheduled_report_postfix',
'manual_report_image',
'manual_report_postfix'
])
export interface ReportImageSequenceRequest {
recipient: WechatActionRequest['recipient']
imagePath: string
postfixText: string
}
export interface ReportImageSequenceResult {
image: WechatActionResult
postfix?: WechatActionResult
}
const AUTOMATION_PURPOSE_ALLOWLIST = new Set(['scheduled_report', 'member_left_notification'])
export interface WechatActionGatewayDependencies {
getCapability?: () => Promise<PersonalWechatSendCapability>
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
getMemberEvent?: (
sourceId: string
) =>
| WechatActionMemberEventReference
| Promise<WechatActionMemberEventReference | undefined>
| undefined
getUserDataPath?: () => string
now?: () => Date
wait?: (milliseconds: number) => Promise<void>
@@ -66,6 +43,10 @@ interface LoadedAuditState {
records: WechatActionAuditRecord[]
}
export interface WechatActionPolicyContext {
memberEvent?: WechatActionMemberEventReference
}
const defaultDependencies = (): Required<
Pick<
WechatActionGatewayDependencies,
@@ -95,6 +76,7 @@ export class WechatActionGateway {
WechatActionGatewayDependencies,
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
>
private readonly memberEvents = new Map<string, WechatActionMemberEventReference>()
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
private auditState: LoadedAuditState | null = null
private automationSendTail: Promise<void> = Promise.resolve()
@@ -104,33 +86,20 @@ export class WechatActionGateway {
this.deps = { ...defaultDependencies(), ...deps }
}
/**
* 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔;
* 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。
*/
async executeReportImageSequence(
request: ReportImageSequenceRequest
): Promise<ReportImageSequenceResult> {
const image = await this.execute({
origin: 'user_manual',
purpose: 'manual_report_image',
triggerType: 'automation',
recipient: request.recipient,
content: { type: 'image', path: String(request.imagePath || '') }
})
if (image.status !== 'sent') return { image }
/** 记录退群事件,发送通知前可确认事件所属群聊。 */
registerMemberEvent(event: WechatActionMemberEventReference): void {
const id = String(event?.id || '').trim()
const roomId = String(event?.roomId || '').trim()
if (!id || !roomId) return
this.memberEvents.set(id, { id, roomId })
}
const postfixText = String(request.postfixText || '').trim()
if (!postfixText) return { image }
registerMemberEvents(events: WechatActionMemberEventReference[]): void {
for (const event of events) this.registerMemberEvent(event)
}
const postfix = await this.execute({
origin: 'user_manual',
purpose: 'manual_report_postfix',
triggerType: 'automation',
recipient: request.recipient,
content: { type: 'text', text: postfixText }
})
return { image, postfix }
clearMemberEvents(): void {
this.memberEvents.clear()
}
listAuditRecords(): WechatActionAuditRecord[] {
@@ -176,7 +145,8 @@ export class WechatActionGateway {
)
}
const policy = evaluateWechatActionPolicy(request)
const eventContext = await this.resolveEventContext(request)
const policy = evaluateWechatActionPolicy(request, eventContext)
if (policy.decision !== 'allow') {
return this.finishBlocked(
actionId,
@@ -270,6 +240,28 @@ export class WechatActionGateway {
return queued
}
private async resolveEventContext(
request: WechatActionRequest
): Promise<WechatActionPolicyContext> {
if (request.purpose !== 'member_left_notification') return {}
const sourceId = String(request.sourceId || '').trim()
if (!sourceId) return {}
let memberEvent: WechatActionMemberEventReference | undefined = this.memberEvents.get(sourceId)
if (!memberEvent && this.deps.getMemberEvent) {
let resolved: WechatActionMemberEventReference | undefined
try {
resolved = await this.deps.getMemberEvent(sourceId)
} catch {
resolved = undefined
}
if (resolved && String(resolved.id || '').trim() === sourceId) {
memberEvent = { id: String(resolved.id), roomId: String(resolved.roomId) }
this.registerMemberEvent(memberEvent)
}
}
return memberEvent ? { memberEvent } : {}
}
private async findExisting(idempotencyKey: string): Promise<WechatActionResult | undefined> {
const state = this.loadAuditState()
const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey)
@@ -288,6 +280,9 @@ export class WechatActionGateway {
private idempotencyKey(request: WechatActionRequest): string | undefined {
const explicit = String(request.idempotencyKey || '').trim()
if (explicit) return explicit
if (request.purpose === 'member_left_notification' && request.sourceId) {
return `member_left_notification:${request.sourceId}`
}
if (request.origin === 'scheduled_report' && request.executionId) {
return `scheduled_report:${request.executionId}`
}
@@ -434,7 +429,10 @@ export class WechatActionGateway {
}
}
export function evaluateWechatActionPolicy(request: WechatActionRequest): PolicyDecision {
export function evaluateWechatActionPolicy(
request: WechatActionRequest,
context: WechatActionPolicyContext = {}
): PolicyDecision {
if (!request || typeof request !== 'object') {
return {
decision: 'block',
@@ -465,6 +463,29 @@ export function evaluateWechatActionPolicy(request: WechatActionRequest): Policy
reason: `自动化动作不允许执行 purpose=${request.purpose}`
}
}
if (request.purpose === 'member_left_notification') {
if (
!request.sourceId ||
!context.memberEvent ||
context.memberEvent.id !== request.sourceId ||
!context.memberEvent.roomId
) {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'INVALID_REQUEST',
reason: '退群通知必须关联已记录的退群事件'
}
}
if (request.recipient.type !== 'group' || request.recipient.id !== context.memberEvent.roomId) {
return {
decision: 'block',
source: 'deterministic',
reasonCode: 'RECIPIENT_SCOPE_VIOLATION',
reason: '退群通知只能发送回原事件所在群聊'
}
}
}
return { decision: 'allow', source: 'deterministic' }
}
@@ -579,32 +600,6 @@ function toPersonalWechatSendRequest(request: WechatActionRequest): PersonalWech
}
}
/**
* 把网关结果还原成既有调用方期望的 `PersonalWechatSendResult`。
*
* 为什么需要:手动发送的 IPC(`wechat-personal:send`)返回契约是
* `PersonalWechatSendResult`,界面上靠 `response.success` / `response.error` 判读。
* 改成走网关之后必须把这个契约**原样**还回去,否则「统一发送路径」会把 UI 一起改坏。
*
* 网关成功时会把底层 `sendResult` 原样挂在 `action.sendResult` 上,优先用它
* (它带着真实的 `status`);拿不到时按 `action.status` 合成一个。
*/
export function toPersonalWechatSendResult(
action: WechatActionResult,
fallbackStatus: PersonalWechatSenderStatus
): PersonalWechatSendResult {
const raw = action.sendResult
if (raw && typeof raw === 'object' && 'success' in (raw as Record<string, unknown>)) {
return raw as PersonalWechatSendResult
}
if (action.status === 'sent') return { success: true, status: fallbackStatus }
return {
success: false,
status: fallbackStatus,
error: action.reason || action.errorCode || '微信发送失败'
}
}
function contentAudit(
content: WechatActionContent
): Pick<WechatActionAuditRecord, 'contentPreview' | 'contentHash'> {
-51
View File
@@ -1,51 +0,0 @@
import { readFileSync } from 'fs'
export interface TrayNativeImage {
addRepresentation(options: { scaleFactor: number; dataURL: string }): void
isEmpty(): boolean
resize(options: { width: number; height: number; quality: 'best' }): TrayNativeImage
setTemplateImage(option: boolean): void
}
export interface TrayNativeImageFactory<T extends TrayNativeImage = TrayNativeImage> {
createEmpty(): T
createFromPath(path: string): T
}
export interface TrayTemplateIconPaths {
oneX: string
twoX: string
}
type ReadImageFile = (path: string) => Buffer
function toPngDataUrl(buffer: Buffer): string {
return `data:image/png;base64,${buffer.toString('base64')}`
}
export function createTrayImage<T extends TrayNativeImage>(
platform: NodeJS.Platform,
appIconPath: string,
templateIconPaths: TrayTemplateIconPaths,
nativeImage: TrayNativeImageFactory<T>,
readImageFile: ReadImageFile = readFileSync
): T {
if (platform === 'darwin') {
const image = nativeImage.createEmpty()
image.addRepresentation({
scaleFactor: 1,
dataURL: toPngDataUrl(readImageFile(templateIconPaths.oneX))
})
image.addRepresentation({
scaleFactor: 2,
dataURL: toPngDataUrl(readImageFile(templateIconPaths.twoX))
})
image.setTemplateImage(true)
return image
}
const image = nativeImage.createFromPath(appIconPath)
return image.isEmpty()
? nativeImage.createEmpty()
: (image.resize({ width: 24, height: 24, quality: 'best' }) as T)
}
+3 -3
View File
@@ -19,9 +19,9 @@ export interface VoiceSilkMetadata {
}
/**
* Validate the exact PCM contract consumed by the bundled SILK encoder.
* The encoder emits a header-only payload for sub-frame input, which would
* produce an unplayable WeChat voice.
* Validate the exact PCM contract consumed by the bundled OneBot encoder.
* The Go Silk encoder emits a header-only payload for sub-frame input, which
* is accepted by the old path but produces an unplayable WeChat voice.
*/
export function validateVoicePcm(
pcm: Uint8Array,
+9 -76
View File
@@ -384,79 +384,6 @@ export function resolveWindowsNativeAccountRoot(
}
}
/**
* native monitor 事件(pipe / socket 上的一行 JSON)。
*
* ## 两个协议版本
*
* **v1(历史)** —— 变更信号来自**文件系统通知**(Windows `ReadDirectoryChangesW` /
* macOS `kqueue`),native 只知道「哪个文件被写了」:
*
* ```json
* {"db":"message_0.db","table":"message","action":"update"}
* ```
*
* 它**不携带会话**,所以上层只能回读「最近活跃会话」。`protocol` 会标成 1。
*
* **v2(Native Monitor Event v2)** —— native 侧在收到文件变化后做一次
* SessionTable watermark diff,把**每一个真正变化的会话**各发一条:
*
* ```json
* {"version":2,"kind":"message_change","session_id":"xxx@chatroom",
* "db":"message_0.db","table":"message","action":"update","observed_at_ms":1}
* ```
*
* `protocol = 2` 且带 `sessionId` 时,上层可以**直接按会话回读**,不再依赖
* `getSessions()[0]`。
*
* **降级规则**:只要 `version`/`kind`/`session_id` 任一不符合 v2 契约,
* 就按 v1 处理(`protocol = 1`)—— 宁可不精确,也不能拿一个不可信的会话 id 去读。
*/
export interface Wcdb4MonitorEvent {
/** pipe 上的原始一行(原样转发给需要它的消费者,例如退群监控)。 */
raw: string
/** 语义化的动作类型。当事件语义出现时用它,比如 `'user_change'`。 */
type: string
action: string
/** `2` = 精确会话事件;`1` = legacy(不含会话)。 */
protocol: 1 | 2
/** 粗表分类:`database` / `message` / `Session` / `contact`。 */
table: string
/** `protocol === 2` 时才有:发生变化的会话(`xxx@chatroom` 或 wxid)。 */
sessionId?: string
/** native 侧观测时刻(epoch ms),仅用于诊断。 */
observedAtMs?: number
}
/** 把 pipe 上的一行 payload 解析成事件。纯函数,便于单测。 */
export function parseMonitorEvent(rawPayload: string): Wcdb4MonitorEvent {
const raw = rawPayload.trim()
let parsed: Record<string, unknown> = {}
try {
const value = JSON.parse(raw) as unknown
if (value && typeof value === 'object' && !Array.isArray(value)) {
parsed = value as Record<string, unknown>
}
} catch {
// 不是 JSON:当作 legacy 事件处理,保留原始 payload。
}
const action = typeof parsed.action === 'string' && parsed.action ? parsed.action : 'update'
const sessionId = typeof parsed.session_id === 'string' ? parsed.session_id.trim() : ''
// 三个条件缺一不可:版本、语义、以及**非空**的会话 id。
const precise = parsed.version === 2 && parsed.kind === 'message_change' && Boolean(sessionId)
return {
raw,
type: action,
action,
protocol: precise ? 2 : 1,
table: typeof parsed.table === 'string' ? parsed.table : '',
...(precise ? { sessionId } : {}),
...(typeof parsed.observed_at_ms === 'number' ? { observedAtMs: parsed.observed_at_ms } : {})
}
}
export class Wcdb4Client {
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
@@ -568,7 +495,7 @@ export class Wcdb4Client {
private wcdbStopMonitorPipe: (() => void) | null = null
private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null
private monitorPipeClient: Socket | null = null
private monitorCallback: ((event: Wcdb4MonitorEvent) => void) | null = null
private monitorCallback: ((type: string, json: string) => void) | null = null
private monitorConnectTimer: ReturnType<typeof setTimeout> | null = null
private monitorReconnectTimer: ReturnType<typeof setTimeout> | null = null
private monitorPipePath = ''
@@ -846,7 +773,7 @@ export class Wcdb4Client {
})
}
async startMonitor(callback: (event: Wcdb4MonitorEvent) => void): Promise<boolean> {
async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> {
if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) {
return false
}
@@ -984,7 +911,13 @@ export class Wcdb4Client {
private emitMonitorPayload(rawPayload: string): void {
const payload = rawPayload.trim()
if (!payload || !this.monitorCallback) return
this.monitorCallback(parseMonitorEvent(payload))
try {
const parsed = JSON.parse(payload) as { action?: string }
this.monitorCallback(parsed.action || 'update', payload)
} catch {
this.monitorCallback('update', payload)
}
}
private scheduleMonitorReconnect(): void {
+50 -52
View File
@@ -82,28 +82,26 @@ import type {
PersonalWechatVoiceDiagnostic
} from '../shared/personal-wechat'
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportNotification,
ScheduledReportNotificationCapability,
ScheduledReportNotificationSettings,
ScheduledReportNotificationSettingsResult,
ScheduledReportResult
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../shared/scheduled-report'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../shared/personal-wechat-runtime'
import type {
PersonalWechatVoiceEncodingEnvironment,
PersonalWechatVoiceEncodingEnvironmentResult
} from '../shared/personal-wechat-voice-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
import type {
AutomationExecution,
AutomationRule,
AutomationRuleDraft,
AutomationStatusSummary,
ScheduledRuleRunOutcome
} from '../shared/automation'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { ActionLogEntry } from '../shared/action-log'
import type {
AppUpdateCheckResult,
@@ -295,37 +293,18 @@ declare global {
avatar: string
}[]
} | null>
getGroupMemberStats: (request: GroupMemberStatsQuery) => Promise<GroupMemberStatsResult>
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
listGroupExitMonitorEvents: (query?: {
roomId?: string
sinceMs?: number
untilMs?: number
limit?: number
}) => Promise<GroupExitMonitorEvent[]>
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
/** 保存**监控范围**。通知配置已迁到「自动化 → 退群通知」。 */
setGroupExitMonitorGroups: (roomIds: string[]) => Promise<GroupExitMonitorState>
setGroupExitMonitorGroups: (
roomIds: string[],
notificationRoomIds?: string[]
) => Promise<GroupExitMonitorState>
setGroupExitMonitorNotificationTemplate: (template: string) => Promise<GroupExitMonitorState>
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
resendGroupExitMonitorEvent: (eventId: string) => Promise<GroupExitMonitorState>
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
listWechatActionLogs: () => Promise<ActionLogEntry[]>
getAutomationStatus: () => Promise<AutomationStatusSummary>
listAutomationRules: () => Promise<AutomationRule[]>
createAutomationRule: (draft: AutomationRuleDraft) => Promise<AutomationRule>
updateAutomationRule: (
id: string,
draft: AutomationRuleDraft
) => Promise<AutomationRule | null>
deleteAutomationRule: (id: string) => Promise<boolean>
setAutomationRuleEnabled: (id: string, enabled: boolean) => Promise<AutomationRule | null>
listAutomationExecutions: (query?: { limit?: number }) => Promise<AutomationExecution[]>
clearAutomationExecutions: () => Promise<boolean>
listAutomationGroups: () => Promise<Array<{ id: string; name: string }>>
/** 保存「退群通知」规则(singleton upsert)。 */
saveLeaveNotificationRule: (draft: AutomationRuleDraft) => Promise<AutomationRule>
/** 「指定好友」的可选项;已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。 */
listSendableContacts: () => Promise<Array<{ id: string; name: string }>>
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
search: (keyword: string) => Promise<string | null>
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
@@ -542,7 +521,6 @@ declare global {
ttsSelectedVoiceId: string
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
windowsWechatPort: string
reportImagePostfixText: string
imageXorKey: string
imageAesKey: string
}
@@ -583,7 +561,6 @@ declare global {
ttsSelectedVoiceId: string
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
windowsWechatPort: string
reportImagePostfixText: string
imageXorKey: string
imageAesKey: string
}
@@ -607,7 +584,6 @@ declare global {
ttsSelectedVoiceId: string
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
windowsWechatPort: string
reportImagePostfixText: string
imageXorKey: string
imageAesKey: string
}>
@@ -721,11 +697,21 @@ declare global {
onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
checkPersonalWechatVoiceEncodingEnvironment: () => Promise<PersonalWechatVoiceEncodingEnvironment>
installPersonalWechatPilk: () => Promise<PersonalWechatVoiceEncodingEnvironmentResult>
openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }>
openPersonalWechatVoiceFfmpegDownload: () => Promise<{ success: boolean; error?: string }>
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
downloadPersonalWechatRuntime: () => Promise<PersonalWechatRuntimeDownloadResult>
cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }>
removePersonalWechatRuntime: () => Promise<PersonalWechatRuntimeStatus>
openPersonalWechatRuntimeDirectory: () => Promise<{ success: boolean; error?: string }>
onPersonalWechatRuntimeProgress: (
callback: (status: PersonalWechatRuntimeProgressEvent) => void
) => () => void
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
sendGeneratedTtsVoice: (
request: PersonalWechatGeneratedTtsVoiceRequest
@@ -733,23 +719,35 @@ declare global {
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
) => Promise<PersonalWechatSendResult>
/** 定时日报:「立即执行」——与 scheduler 走同一条执行链路(manual trigger)。 */
runScheduledReportRule: (
ruleId: string
) => Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>
/** 定时日报:旧执行记录**只读存档**(旧记录无法无损转换,原样展示)。 */
listScheduledReportLegacyExecutions: (
ruleId?: string
) => Promise<ScheduledReportExecution[]>
/** 定时日报:微信异常通知开关与能力检测。 */
listScheduledReports: () => Promise<ScheduledReportTask[]>
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
getScheduledReportNotificationCapability: () => Promise<ScheduledReportNotificationCapability>
setScheduledReportNotificationEnabled: (
enabled: boolean
) => Promise<ScheduledReportNotificationSettingsResult>
createScheduledReport: (
request: ScheduledReportCreateInput
) => Promise<ScheduledReportResult<ScheduledReportTask>>
updateScheduledReport: (
taskId: string,
request: ScheduledReportUpdateInput
) => Promise<ScheduledReportResult<ScheduledReportTask>>
deleteScheduledReport: (
taskId: string
) => Promise<ScheduledReportResult<{ deletedId: string }>>
setScheduledReportEnabled: (
taskId: string,
enabled: boolean
) => Promise<ScheduledReportResult<ScheduledReportTask>>
runScheduledReportNow: (
taskId: string
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
retryScheduledReportSend: (
executionId: string
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
testScheduledReportErrorNotification: (
ruleId: string
) => Promise<ScheduledReportResult<ScheduledReportNotification>>
taskId: string
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
+78 -72
View File
@@ -52,28 +52,26 @@ import type {
} from '../shared/personal-wechat'
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportNotification,
ScheduledReportNotificationCapability,
ScheduledReportNotificationSettings,
ScheduledReportNotificationSettingsResult,
ScheduledReportResult
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../shared/scheduled-report'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../shared/personal-wechat-runtime'
import type {
PersonalWechatVoiceEncodingEnvironment,
PersonalWechatVoiceEncodingEnvironmentResult
} from '../shared/personal-wechat-voice-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update'
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
import type {
AutomationExecution,
AutomationRule,
AutomationRuleDraft,
AutomationStatusSummary,
ScheduledRuleRunOutcome
} from '../shared/automation'
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { ActionLogEntry } from '../shared/action-log'
import type { CacheClearScope, CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress } from '../shared/export'
@@ -169,59 +167,29 @@ const api = {
): Promise<MessagesAroundResult> =>
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
getGroupMemberStats: (request: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> =>
ipcRenderer.invoke('group-stats:getMemberStats', request),
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:getState'),
listGroupExitMonitorEvents: (query?: {
roomId?: string
sinceMs?: number
untilMs?: number
limit?: number
}): Promise<GroupExitMonitorEvent[]> =>
ipcRenderer.invoke('group-exit-monitor:listEvents', query),
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
/** 保存**监控范围**。通知配置已迁到自动化规则,这里不再有第二个参数。 */
setGroupExitMonitorGroups: (roomIds: string[]): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds),
setGroupExitMonitorGroups: (
roomIds: string[],
notificationRoomIds?: string[]
): Promise<GroupExitMonitorState> =>
notificationRoomIds === undefined
? ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds)
: ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds, notificationRoomIds),
setGroupExitMonitorNotificationTemplate: (template: string): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setTemplate', template),
checkGroupExitMonitorNow: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:checkNow'),
clearGroupExitMonitorEvents: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
resendGroupExitMonitorEvent: (eventId: string): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:resendEvent', eventId),
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
ipcRenderer.invoke('wechat-action-log:list'),
// ---- Automation v1(@我生成日报)。命名与既有扁平风格一致。 ----
getAutomationStatus: (): Promise<AutomationStatusSummary> =>
ipcRenderer.invoke('automation:getStatus'),
listAutomationRules: (): Promise<AutomationRule[]> => ipcRenderer.invoke('automation:listRules'),
createAutomationRule: (draft: AutomationRuleDraft): Promise<AutomationRule> =>
ipcRenderer.invoke('automation:createRule', draft),
updateAutomationRule: (id: string, draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
ipcRenderer.invoke('automation:updateRule', { id, draft }),
deleteAutomationRule: (id: string): Promise<boolean> =>
ipcRenderer.invoke('automation:deleteRule', id),
setAutomationRuleEnabled: (id: string, enabled: boolean): Promise<AutomationRule | null> =>
ipcRenderer.invoke('automation:setRuleEnabled', { id, enabled }),
listAutomationExecutions: (query?: { limit?: number }): Promise<AutomationExecution[]> =>
ipcRenderer.invoke('automation:listExecutions', query),
clearAutomationExecutions: (): Promise<boolean> =>
ipcRenderer.invoke('automation:clearExecutions'),
/** 「在哪些聊天生效」的可选项。`id` 为 `xxx@chatroom`,与规则内 conversationIds 同口径。 */
listAutomationGroups: (): Promise<Array<{ id: string; name: string }>> =>
ipcRenderer.invoke('automation:listGroups'),
/**
* 保存「退群通知」规则(singleton upsert,id 由 main 侧固定,渲染层不拼 id)。
*/
saveLeaveNotificationRule: (draft: AutomationRuleDraft): Promise<AutomationRule> =>
ipcRenderer.invoke('automation:saveLeaveNotificationRule', draft),
/**
* 「指定好友」的可选项。已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。
*/
listSendableContacts: (): Promise<Array<{ id: string; name: string }>> =>
ipcRenderer.invoke('automation:listSendableContacts'),
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
callback(state)
@@ -551,6 +519,10 @@ const api = {
ipcRenderer.invoke('wechat-personal:getStatus'),
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
ipcRenderer.invoke('wechat-personal:getSendCapability'),
getPersonalWechatKeepOneBotProcess: (): Promise<boolean> =>
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
ipcRenderer.invoke('wechat-personal:setKeepProcess', keep),
checkPersonalWechatSenderStatus: (port?: string): Promise<PersonalWechatSenderStatus> =>
ipcRenderer.invoke('wechat-personal:checkStatus', port),
checkPersonalWechatVoiceEncodingEnvironment:
@@ -562,6 +534,26 @@ const api = {
ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'),
openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('wechat-personal:openVoiceFfmpegDownload'),
getPersonalWechatRuntimeStatus: (): Promise<PersonalWechatRuntimeStatus> =>
ipcRenderer.invoke('wechat-personal:getRuntimeStatus'),
downloadPersonalWechatRuntime: (): Promise<PersonalWechatRuntimeDownloadResult> =>
ipcRenderer.invoke('wechat-personal:downloadRuntime'),
cancelPersonalWechatRuntimeDownload: (): Promise<{ success: boolean }> =>
ipcRenderer.invoke('wechat-personal:cancelRuntimeDownload'),
removePersonalWechatRuntime: (): Promise<PersonalWechatRuntimeStatus> =>
ipcRenderer.invoke('wechat-personal:removeRuntime'),
openPersonalWechatRuntimeDirectory: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('wechat-personal:openRuntimeDirectory'),
onPersonalWechatRuntimeProgress: (
callback: (status: PersonalWechatRuntimeProgressEvent) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
status: PersonalWechatRuntimeProgressEvent
): void => callback(status)
ipcRenderer.on('wechat-personal:runtimeProgress', listener)
return () => ipcRenderer.removeListener('wechat-personal:runtimeProgress', listener)
},
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
ipcRenderer.invoke('wechat-personal:rebind'),
sendGeneratedTtsVoice: (
@@ -572,30 +564,44 @@ const api = {
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
/**
* 定时日报(Automation 的 `scheduled_report` 规则类型)。
*
* 规则 CRUD 复用上面的 `automation:*` 通道;这里只暴露三块专属能力:
* 立即执行、微信异常通知、旧执行记录只读存档。
*/
runScheduledReportRule: (
ruleId: string
): Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }> =>
ipcRenderer.invoke('automation:runScheduledReportRule', ruleId),
listScheduledReportLegacyExecutions: (ruleId?: string): Promise<ScheduledReportExecution[]> =>
ipcRenderer.invoke('automation:listScheduledReportLegacyExecutions', ruleId),
listScheduledReports: (): Promise<ScheduledReportTask[]> =>
ipcRenderer.invoke('scheduled-report:list'),
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
ipcRenderer.invoke('automation:getScheduledReportNotificationSettings'),
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
ipcRenderer.invoke('automation:getScheduledReportNotificationCapability'),
ipcRenderer.invoke('scheduled-report:getNotificationSettings'),
setScheduledReportNotificationEnabled: (
enabled: boolean
): Promise<ScheduledReportNotificationSettingsResult> =>
ipcRenderer.invoke('automation:setScheduledReportNotificationEnabled', enabled),
ipcRenderer.invoke('scheduled-report:setNotificationEnabled', enabled),
createScheduledReport: (
request: ScheduledReportCreateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:create', request),
updateScheduledReport: (
taskId: string,
request: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:update', taskId, request),
deleteScheduledReport: (taskId: string): Promise<ScheduledReportResult<{ deletedId: string }>> =>
ipcRenderer.invoke('scheduled-report:delete', taskId),
setScheduledReportEnabled: (
taskId: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:setEnabled', taskId, enabled),
runScheduledReportNow: (
taskId: string
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
ipcRenderer.invoke('scheduled-report:runNow', taskId),
retryScheduledReportSend: (
executionId: string
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
ipcRenderer.invoke('scheduled-report:retrySend', executionId),
testScheduledReportErrorNotification: (
ruleId: string
): Promise<ScheduledReportResult<ScheduledReportNotification>> =>
ipcRenderer.invoke('automation:testScheduledReportErrorNotification', ruleId),
taskId: string
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId),
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
+33 -84
View File
@@ -18,6 +18,7 @@ import { ReportInfoPanel } from './components/reports/ReportInfoPanel'
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
import { ReportViewer } from './components/reports/ReportViewer'
import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace'
import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace'
import { contactDisplayName } from './components/reports/types'
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
@@ -41,11 +42,10 @@ import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/me
import { enrichQuotedMessages } from './utils/quoted-messages'
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
import { runtimePlatform } from './utils/runtime-environment'
import { Button, useToast } from './components/ui'
import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
import { useToast } from './components/ui'
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace'
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
import {
buildContactSearchIndex,
@@ -257,17 +257,6 @@ function App(): React.ReactElement {
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
const connectionOperationRef = React.useRef(0)
const [activePage, setActivePage] = useState<AppPage>('archive')
/**
* 「退群监控 ⇄ 自动化」之间的跨页深链。
*
* 项目没有 react-router,一级菜单就是 `activePage` 这一份 state,
* 所以深链同样是 state —— 用一个单调递增的 requestId 表达"又点了一次",
* 子页面据此重新落位(同一个对象引用不会重复触发 effect)。
*/
const [automationOpenRuleRequest, setAutomationOpenRuleRequest] =
React.useState<AutomationOpenRuleRequest | null>(null)
const [exitMonitorOpenViewRequest, setExitMonitorOpenViewRequest] =
React.useState<GroupExitMonitorOpenViewRequest | null>(null)
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
/**
* 精确跳转目标(规范化后的消息 id)。
@@ -279,7 +268,7 @@ function App(): React.ReactElement {
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
const [reportSection, setReportSection] = useState<'today' | 'market'>('today')
const [reportSection, setReportSection] = useState<'today' | 'scheduled' | 'market'>('today')
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
@@ -1650,49 +1639,10 @@ function App(): React.ReactElement {
setActivePage('settings')
}
/**
* 跳到「设置 · 本地索引」。
*
* 用在群统计这类场景:结果不完整的原因是索引没追平,用户需要一个**可操作的去处**,
* 而不是只看到一句「结果可能不完整」却不知道去哪解决。
*/
const openLocalIndexSettings = (): void => {
setSettingsCategory('local-index')
setActivePage('settings')
}
const openAgentHub = (): void => {
setActivePage('agent-hub')
}
/**
* 「退群监控 → 退群通知自动化」。
*
* 直接落到「自动化 → 规则 → 退群通知」,不是只跳到自动化首页。
* 这是纯导航:不改退群监控的任何配置,也不触发任何发送。
*/
const openLeaveNotificationAutomation = (): void => {
setAutomationOpenRuleRequest({ ruleType: 'leave_notification', requestId: Date.now() })
setActivePage('automation')
}
/**
* 「定时日报」已正式迁入自动化,日报页只保留「今日日报 | 社区模板市场」两个 Tab。
*
* 这里仍然留一个导航入口:用户是从日报页产生"要定时发日报"这个念头的,
* 不给路会显得功能被删了。它**不是**第三个 Tab,只是一句去处的说明。
*/
const openScheduledReportAutomation = (): void => {
setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() })
setActivePage('automation')
}
/** 「自动化 → 退群通知 → 管理监控群聊 / 查看群聊」:回到退群监控的管理群聊页。 */
const openExitMonitorGroups = (): void => {
setExitMonitorOpenViewRequest({ view: 'manage', requestId: Date.now() })
setActivePage('exit-monitor')
}
const dismissFirstUseWelcome = (): void => {
try {
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
@@ -1951,12 +1901,12 @@ function App(): React.ReactElement {
contentFilter={contentFilter}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
onRefreshData={loadContacts}
onReloadAvatars={handleReloadCurrentAvatars}
onLoadOlderMessages={handleLoadOlderMessages}
onCreateGroupReport={handleOpenReportWorkspace}
onOpenTextToSpeechSettings={openTextToSpeechSettings}
onOpenPersonalWechatSettings={openWechatSendSettings}
onOpenLocalIndexSettings={openLocalIndexSettings}
isAiLoading={reportGeneration.isGenerating}
jumpToTime={archiveJumpTime}
jumpToMessageId={archiveJumpMessageId}
@@ -1976,6 +1926,22 @@ function App(): React.ReactElement {
>
今日日报
</button>
<button
type="button"
role="tab"
aria-selected={reportSection === 'scheduled'}
aria-disabled={!supportsPersonalWechatSend}
className={`${reportSection === 'scheduled' ? 'active' : ''} ${!supportsPersonalWechatSend ? 'unsupported' : ''}`}
onClick={() => {
if (!supportsPersonalWechatSend) {
toast({ description: '定时日报目前仅支持 macOS 和 Windows。', duration: 3200 })
return
}
setReportSection('scheduled')
}}
>
定时日报{!supportsPersonalWechatSend && <small>仅 macOS / Windows</small>}
</button>
<button
type="button"
role="tab"
@@ -1986,26 +1952,23 @@ function App(): React.ReactElement {
社区模板市场
</button>
</div>
{/*
「定时日报已并入自动化」的指引条。
这里以前是裸 `<p>` + 裸 `<button>`,并且挂了 `.report-workspace-hint` /
`.report-workspace-hint-link` 两个**样式表里根本不存在**的类 ——
于是就成了一条没样式的文字 + 一个长得不像系统里任何按钮的按钮。
现在补上真实样式,按钮统一走 UI 组件库。
*/}
<div className="report-workspace-hint">
<span>需要「定时日报」?它已经并入「自动化」,可以按时间自动生成并发送日报。</span>
<Button variant="link" size="sm" onClick={openScheduledReportAutomation}>
去自动化配置 →
</Button>
</div>
<div className="report-workspace-body">
{reportSection === 'market' ? (
<ReportTemplateMarketWorkspace
value={reportGeneration.templateId}
onChange={reportGeneration.setTemplateId}
/>
) : reportSection === 'scheduled' ? (
<ScheduledReportsWorkspace
contacts={contacts}
platformSupported={supportsPersonalWechatSend}
onOpenWechatSettings={openWechatSendSettings}
onOpenAgentHub={openAgentHub}
onOpenModelSettings={openModelSettings}
onNotice={(message, variant) =>
toast({ description: message, variant, duration: 3200 })
}
/>
) : reportWorkspaceView === 'result' ? (
<div className="report-center-page">
<ReportHistorySidebar
@@ -2121,20 +2084,6 @@ function App(): React.ReactElement {
dbReady={isDatabaseConnected}
contacts={contacts}
onOpenSendSettings={openWechatSendSettings}
openViewRequest={exitMonitorOpenViewRequest}
onOpenViewRequestHandled={() => setExitMonitorOpenViewRequest(null)}
onOpenLeaveNotificationAutomation={openLeaveNotificationAutomation}
/>
)
case 'automation':
return (
<AutomationWorkspace
dbReady={isDatabaseConnected}
onOpenSendSettings={openWechatSendSettings}
onOpenExitMonitorGroups={openExitMonitorGroups}
openRuleRequest={automationOpenRuleRequest}
onOpenModelSettings={openModelSettings}
onOpenAgentHub={openAgentHub}
/>
)
case 'agent-hub':
+5 -20
View File
@@ -7,8 +7,6 @@ import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState'
import { MessageList } from './chat/MessageList'
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
import { mergeGroupExitEvents } from '../../../shared/group-exit-event-message'
import { useGroupExitEvents } from '../hooks/useGroupExitEvents'
interface ChatWindowProps {
contact: Contact | null
@@ -18,13 +16,12 @@ interface ChatWindowProps {
contentFilter?: string
onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onReloadAvatars?: () => Promise<void>
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
onOpenTextToSpeechSettings?: () => void
onOpenPersonalWechatSettings?: () => void
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
onOpenLocalIndexSettings?: () => void
isAiLoading?: boolean
jumpToTime?: number | null
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
@@ -39,12 +36,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
contentFilter,
onContentFilterChange,
onRefresh,
onRefreshData,
onReloadAvatars,
onLoadOlderMessages,
onCreateGroupReport,
onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings,
onOpenLocalIndexSettings,
isAiLoading = false,
jumpToTime,
jumpToMessageId
@@ -125,20 +122,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}
}
/**
* 退群推断事件并入档案消息流。
*
* **先合并再过滤**:反过来的话搜索词就命中不了事件,而「谁退群了」
* 恰恰是最需要能被搜到的内容之一。
*/
const groupExitEvents = useGroupExitEvents(contact)
const allMessages = React.useMemo(
() => (groupExitEvents.length ? mergeGroupExitEvents(messages, groupExitEvents) : messages),
[messages, groupExitEvents]
)
const filteredMessages = React.useMemo(() => {
return allMessages.filter((msg) => {
return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
.split(',')
.map((type) => type.trim())
@@ -147,7 +132,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
return typeMatch && contentMatch
})
}, [allMessages, contentFilter])
}, [messages, contentFilter])
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
try {
@@ -180,9 +165,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
isAiLoading={isAiLoading}
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onRefreshData={onRefreshData}
onTestSend={() => void handleOpenPersonalWechatSend()}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
/>
<DataTrustBar messageCount={messages.length} />
<MessageList
@@ -26,7 +26,11 @@ export function AccountSummary({
const displayName =
showAccount && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
const subtitle = showAccount && selfInfo ? selfInfo.wxid : '打开设置'
const statusText = dbReady ? '数据库已连接' : dbConnecting ? '正在连接数据库' : '数据库未连接'
const statusText = dbReady
? '数据库已连接'
: dbConnecting
? '正在连接数据库'
: '数据库未连接'
const statusClass = dbReady ? 'ready' : dbConnecting ? 'connecting' : ''
const initial = (displayName || '?').charAt(0)
const title = `${displayName}\n${subtitle}`
@@ -62,8 +66,8 @@ export function AccountSummary({
</span>
<button type="button" className="account-summary-settings" onClick={onClick} title="设置">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12.22 2h-.44a2 2 0 0 0-2 1.72l-.15 1.58a8 8 0 0 0-1.77 1.03l-1.48-.6a2 2 0 0 0-2.5.88l-.22.39a2 2 0 0 0 .51 2.68l1.25.98a8 8 0 0 0 0 2.08l-1.25.98a2 2 0 0 0-.51 2.68l.22.39a2 2 0 0 0 2.5.88l1.48-.6c.54.43 1.14.78 1.77 1.03l.15 1.58a2 2 0 0 0 2 1.72h.44a2 2 0 0 0 2-1.72l.15-1.58c.63-.25 1.23-.6 1.77-1.03l1.48.6a2 2 0 0 0 2.5-.88l.22-.39a2 2 0 0 0-.51-2.68l-1.25-.98a8 8 0 0 0 0-2.08l1.25-.98a2 2 0 0 0 .51-2.68l-.22-.39a2 2 0 0 0-2.5-.88l-1.48.6a8 8 0 0 0-1.77-1.03l-.15-1.58a2 2 0 0 0-2-1.72Z" />
<circle cx="12" cy="12" r="3" />
<path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" />
<path d="M19.4 15a8.2 8.2 0 0 0 .1-1l2-1.5-2-3.5-2.4 1a7.5 7.5 0 0 0-1.7-1l-.3-2.5h-4l-.4 2.5a7.5 7.5 0 0 0-1.7 1l-2.3-1-2 3.5 2 1.5a8.2 8.2 0 0 0 0 2l-2 1.5 2 3.5 2.3-1a7.5 7.5 0 0 0 1.7 1l.4 2.5h4l.3-2.5a7.5 7.5 0 0 0 1.7-1l2.4 1 2-3.5-2.1-1.5Z" />
</svg>
</button>
</div>
+25 -30
View File
@@ -1,10 +1,19 @@
import React, { useState } from 'react'
import { Contact } from '../../../../shared/types'
import { Button, IconButton, Tooltip, TooltipContent, TooltipTrigger } from '../ui'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Tooltip,
TooltipContent,
TooltipTrigger
} from '../ui'
import { ConversationContentSearch } from './ConversationContentSearch'
import { AiIcon, RefreshIcon, SearchIcon, SendIcon, StatsIcon } from './icons'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
import { GroupMemberStatsDialog } from '../group-stats/GroupMemberStatsDialog'
interface ChatHeaderProps {
contact: Contact
@@ -15,10 +24,9 @@ interface ChatHeaderProps {
isAiLoading: boolean
onContentFilterChange: (value: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onTestSend: () => void
onOpenAiSettings: () => void
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
onOpenLocalIndexSettings?: () => void
}
export function ChatHeader({
@@ -30,12 +38,11 @@ export function ChatHeader({
isAiLoading,
onContentFilterChange,
onRefresh,
onRefreshData,
onTestSend,
onOpenAiSettings,
onOpenLocalIndexSettings
onOpenAiSettings
}: ChatHeaderProps): React.ReactElement {
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
const [statsOpen, setStatsOpen] = useState(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const typeLabel = isGroupChat ? '群聊' : '联系人'
const visibleCount = contentFilter ? filteredCount : loadedCount
@@ -84,20 +91,16 @@ export function ChatHeader({
<IconButton label="刷新聊天记录" variant="ghost" className="h-8 w-8" onClick={onRefresh}>
<RefreshIcon />
</IconButton>
{/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */}
{isGroupChat ? (
<Button
variant="outline"
size="sm"
className="chat-header-text-action"
aria-label="群发言统计"
title="群发言统计"
onClick={() => setStatsOpen(true)}
>
<StatsIcon />
<span>群发言统计</span>
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton label="更多" tooltip="" variant="ghost" className="h-8 w-8">
<MoreIcon />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onRefreshData?.()}>刷新数据</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{supportsPersonalWechatSend ? (
<Button
variant="outline"
@@ -145,14 +148,6 @@ export function ChatHeader({
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
</Button>
</div>
{isGroupChat ? (
<GroupMemberStatsDialog
open={statsOpen}
onOpenChange={setStatsOpen}
contact={contact}
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
/>
) : null}
</div>
)
}
@@ -1,27 +0,0 @@
import React from 'react'
import type { Message } from '../../../../shared/types'
interface GroupExitEventBubbleProps {
message: Message
}
/**
* 退群推断事件在档案里的展示。
*
* ⚠️ 与真实微信系统消息**必须视觉可区分**,原因不是审美:
* 这条内容**不是微信说的话**,而是 TraceMemo 通过「群成员快照前后对比」推断出来的。
* 两者的可信度完全不同 —— 微信系统消息是事实原文,这条是推断产物,
* 而且它记录的是**检测时刻**(可能比真实退群时间晚几分钟到几天)。
*
* 所以它带一个「本地推断」标记,且样式与 `wechat-system-message` 明确区分。
*/
export function GroupExitEventBubble({ message }: GroupExitEventBubbleProps): React.ReactElement {
return (
<div className="group-exit-event-row">
<div className="group-exit-event-bubble">
<span className="group-exit-event-tag">本地推断</span>
<span className="group-exit-event-text">{message.content}</span>
</div>
</div>
)
}
@@ -1,7 +1,5 @@
import React from 'react'
import { Contact } from '../../../../shared/types'
import { isGroupExitEventMessage } from '../../../../shared/group-exit-event-message'
import { GroupExitEventBubble } from './GroupExitEventBubble'
import { MessageBubble } from './MessageBubble'
import { MessageGroupModel } from './messageGrouping'
@@ -28,17 +26,11 @@ export function MessageGroup({
return (
<>
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
{group.messages.map((message) =>
// 退群推断事件与真实微信系统消息都落在 isSystem 分支,
// 但必须分开渲染:前者是本地推断,后者是微信原话。
isGroupExitEventMessage(message) ? (
<GroupExitEventBubble key={message.id} message={message} />
) : (
<div key={message.id} className="wechat-system-message-row">
<div className="wechat-system-message">{message.content}</div>
</div>
)
)}
{group.messages.map((message) => (
<div key={message.id} className="wechat-system-message-row">
<div className="wechat-system-message">{message.content}</div>
</div>
))}
</>
)
}
@@ -5,14 +5,16 @@ import type {
PersonalWechatSenderStatus,
PersonalWechatVoiceDiagnostic
} from '../../../../shared/personal-wechat'
import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../shared/personal-wechat-runtime'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { isMac, isWindows } from '../../utils/runtime-environment'
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
type SelectedLocalFile = { path: string; name: string }
@@ -34,7 +36,7 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
sipDisabled: false,
wechatRunning: false,
runtimeReady: false,
endpoint: '',
endpoint: '127.0.0.1:58080',
endpointReady: false,
attachReady: false,
baseAddressReady: false,
@@ -65,14 +67,18 @@ function PersonalWechatMacSendDialog({
initialImage = null
}: PersonalWechatSendDialogProps): React.ReactElement {
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
const [runtimeProgress, setRuntimeProgress] = useState<PersonalWechatRuntimeProgressEvent | null>(
null
)
const [detecting, setDetecting] = useState(true)
const [binding, setBinding] = useState(false)
const [runtimeBusy, setRuntimeBusy] = useState(false)
const [sendBusy, setSendBusy] = useState(false)
// 发送入口只信任当前 native runtime 上报的能力状态。
// 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。
const [sessionBound, setSessionBound] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [sendError, setSendError] = useState<string | null>(null)
const [sendSuccess, setSendSuccess] = useState<string | null>(null)
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
const requestIdRef = useRef(0)
@@ -80,19 +86,23 @@ function PersonalWechatMacSendDialog({
const closingRef = useRef(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const targetId = contact.m_nsUsrName
const isBusy = binding || sendBusy
const isBusy = binding || runtimeBusy || sendBusy
const setupReady = Boolean(initialImage ? senderStatus?.canSendImage : senderStatus?.canSendVoice)
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
Boolean(initialImage)
)
const refreshStatus = useCallback(async (): Promise<void> => {
const requestId = ++requestIdRef.current
setDetecting(true)
setSendError(null)
try {
const nextSender = await window.api.getPersonalWechatSenderStatus()
const [nextRuntime, nextSender] = await Promise.all([
isMac
? window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null)
: Promise.resolve(null),
window.api.getPersonalWechatSenderStatus()
])
if (requestId !== requestIdRef.current) return
setRuntimeStatus(nextRuntime)
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
setSenderStatus(nextSender)
setSessionBound(
nextSender.state === 'online' ||
@@ -112,6 +122,13 @@ function PersonalWechatMacSendDialog({
useEffect(() => {
void refreshStatus()
if (!isMac) return undefined
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
setRuntimeProgress(status)
setRuntimeStatus(status)
if (status.state === 'ready') void refreshStatus()
})
return unsubscribe
}, [refreshStatus])
useEffect(() => {
@@ -129,6 +146,22 @@ function PersonalWechatMacSendDialog({
return () => window.clearInterval(timer)
}, [refreshStatus, senderStatus])
const handleDownloadRuntime = async (): Promise<void> => {
if (runtimeBusy) return
setRuntimeBusy(true)
setSendError(null)
try {
const result = await window.api.downloadPersonalWechatRuntime()
setRuntimeStatus(result.status)
if (!result.success && result.error) setSendError(result.error)
if (result.success) await refreshStatus()
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error))
} finally {
setRuntimeBusy(false)
}
}
const handleBind = async (): Promise<void> => {
if (binding) return
setBinding(true)
@@ -199,27 +232,18 @@ function PersonalWechatMacSendDialog({
if (!initialImage || !senderStatus?.canSendImage || sendBusy) return
setSendBusy(true)
setSendError(null)
setSendSuccess(null)
try {
await persistPostfixText()
const response = await window.api.sendPersonalWechatMessage({
type: 'image',
to: targetId,
isGroup: isGroupChat,
filePath: initialImage.path,
postfixText
filePath: initialImage.path
} satisfies PersonalWechatSendRequest)
setSenderStatus(response.status)
if (!response.success) {
setSendError(response.error || '日报图片发送失败')
return
}
if (response.postfixError) {
setSendError(`日报图片已发送,但${response.postfixError}`)
setSendSuccess('日报图片发送成功')
} else {
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
}
setMessages((current) => [
...current,
{
@@ -263,7 +287,7 @@ function PersonalWechatMacSendDialog({
{displayName.slice(0, 1)}
</div>
<div className="personal-wechat-chat-heading">
<DialogTitle>{initialImage ? '发送日报图片' : '文字转语音'}</DialogTitle>
<DialogTitle>文字转语音</DialogTitle>
<DialogDescription>
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
</DialogDescription>
@@ -295,10 +319,14 @@ function PersonalWechatMacSendDialog({
{!setupReady && senderStatus && (
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
runtimeProgress={runtimeProgress}
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
sessionBound={sessionBound}
onDownloadRuntime={() => void handleDownloadRuntime()}
onBind={() => void handleBind()}
onStartSending={() => undefined}
onOpenTextToSpeechSettings={handleOpenSettings}
@@ -308,16 +336,6 @@ function PersonalWechatMacSendDialog({
{setupReady && initialImage && (
<section className="personal-wechat-composer" aria-label="日报图片发送">
<p>已准备日报图片:{initialImage.name}</p>
<ReportImagePostfixInput
value={postfixText}
onChange={setPostfixText}
onBlur={() =>
void persistPostfixText().catch((error) =>
setSendError(error instanceof Error ? error.message : '发送后置词保存失败')
)
}
disabled={sendBusy}
/>
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
{sendBusy ? '发送中…' : '发送日报图片'}
</Button>
@@ -340,13 +358,8 @@ function PersonalWechatMacSendDialog({
{sendError}
</div>
)}
{sendSuccess && (
<div className="personal-wechat-global-success" role="status">
{sendSuccess}
</div>
)}
</div>
{setupReady && isMac && !initialImage && (
{setupReady && isMac && (
<div className="personal-wechat-chat-footer flex items-center justify-end">
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
语音发送诊断
@@ -1,13 +1,22 @@
import { useState } from 'react'
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
import { Button } from '../ui'
const GROUP_README_URL = 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
import type {
PersonalWechatRuntimeProgressEvent,
PersonalWechatRuntimeStatus
} from '../../../../shared/personal-wechat-runtime'
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { isMac } from '../../utils/runtime-environment'
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
interface PersonalWechatSetupGuideProps {
runtimeStatus: PersonalWechatRuntimeStatus | null
senderStatus: PersonalWechatSenderStatus | null
runtimeProgress: PersonalWechatRuntimeProgressEvent | null
runtimeBusy: boolean
binding: boolean
detecting: boolean
sessionBound: boolean
onDownloadRuntime: () => void
onBind: () => void
onStartSending: () => void
onOpenTextToSpeechSettings?: () => void
@@ -18,18 +27,21 @@ function capabilityLabel(ready: boolean, initializing: boolean): string {
return initializing ? '初始化中' : '未就绪'
}
function diagnosticValue(value: unknown): string {
if (value === undefined || value === null || value === '') return '未检测到'
return String(value)
}
function bindingHint(status: PersonalWechatSenderStatus): string {
if (status.state === 'wechat_not_running') {
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
}
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
if (status.state === 'unsupported_version')
return '当前微信版本暂不支持,请联系群主确认可用版本。'
return '当前微信版本暂不支持,请查看微信发送设置中的支持版本。'
if (status.state === 'runtime_missing') {
return '请先完成发送运行时准备。'
return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
}
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
}
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
@@ -42,21 +54,28 @@ function isWechatBound(status: PersonalWechatSenderStatus): boolean {
}
export function PersonalWechatSetupGuide({
runtimeStatus,
senderStatus,
runtimeProgress,
runtimeBusy,
binding,
detecting,
sessionBound,
onDownloadRuntime,
onBind,
onStartSending,
onOpenTextToSpeechSettings
}: PersonalWechatSetupGuideProps): React.ReactElement {
const runtimeUnavailable = senderStatus?.state === 'runtime_missing'
const runtimeLabel = '发送运行时'
const runtimeReady = senderStatus?.runtimeReady === true
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
const canSendVoice = Boolean(senderStatus?.canSendVoice)
const initializing = connected && !canSendVoice && senderStatus?.state !== 'error'
const allReady = connected && Boolean(senderStatus?.canSend)
const progress = runtimeProgress || runtimeStatus
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
return (
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
@@ -70,21 +89,38 @@ export function PersonalWechatSetupGuide({
</div>
<ol className="personal-wechat-steps">
<li className={runtimeReady ? 'is-complete' : 'is-current'}>
<li
className={runtimeReady ? 'is-complete' : runtimeDownloading ? 'is-active' : 'is-current'}
>
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
<div className="personal-wechat-step-content">
<strong>准备 {runtimeLabel}</strong>
<p>
{runtimeUnavailable
? `个人微信发送需要${runtimeLabel},授权后即可使用。`
: `${runtimeLabel}随 TraceMemo 一起提供,无需额外安装。`}
</p>
{runtimeReady ? (
<span className="personal-wechat-step-status">✓ {runtimeLabel}已就绪</span>
) : runtimeUnavailable ? (
<span className="personal-wechat-step-error">当前版本暂未提供微信消息发送功能</span>
<p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
{runtimeDownloading ? (
<div className="personal-wechat-download-progress" aria-live="polite">
<div>
<span>正在准备 {runtimeLabel}</span>
<span>{progressPercent}%</span>
</div>
<div className="personal-wechat-progress-track">
<span style={{ width: `${progressPercent}%` }} />
</div>
</div>
) : runtimeReady ? (
<span className="personal-wechat-step-status">✓ {runtimeLabel}已准备</span>
) : (
<span className="personal-wechat-step-status">正在检查 {runtimeLabel}…</span>
<Button
size="sm"
onClick={onDownloadRuntime}
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
>
下载运行时
</Button>
)}
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
<p className="personal-wechat-step-error">
运行时准备失败,请重试或查看微信发送设置。
</p>
)}
</div>
</li>
@@ -103,7 +139,7 @@ export function PersonalWechatSetupGuide({
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
<div className="personal-wechat-step-content">
<strong>绑定个人微信</strong>
<p>请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。</p>
<p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
{connected ? (
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
) : (
@@ -111,28 +147,15 @@ export function PersonalWechatSetupGuide({
size="sm"
variant="outline"
onClick={onBind}
disabled={runtimeUnavailable || !runtimeReady || binding}
disabled={!runtimeReady || binding || runtimeDownloading}
>
{binding ? '正在绑定…' : '绑定微信'}
</Button>
)}
{!connected && senderStatus && !runtimeUnavailable && (
{!connected && senderStatus && (
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
)}
{!connected && runtimeUnavailable && (
<div className="personal-wechat-authorization-warning" role="alert">
<span aria-hidden>!</span>
<p>
发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。
进群请点击{' '}
<a href={GROUP_README_URL} target="_blank" rel="noreferrer">
这里
</a>{' '}
跳转。
</p>
</div>
)}
{!connected && !runtimeUnavailable && (
{!connected && (
<p className="personal-wechat-step-warning" role="note">
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
<br />
@@ -140,6 +163,14 @@ export function PersonalWechatSetupGuide({
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
</p>
)}
<Button
className="w-fit"
variant="link"
size="sm"
onClick={() => setShowSupportedVersions(true)}
>
查看支持的微信版本
</Button>
</div>
</li>
@@ -194,7 +225,7 @@ export function PersonalWechatSetupGuide({
</li>
</ol>
{!runtimeReady && runtimeUnavailable && onOpenTextToSpeechSettings && (
{!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
<Button
variant="link"
size="sm"
@@ -204,6 +235,54 @@ export function PersonalWechatSetupGuide({
查看语音设置
</Button>
)}
<Dialog open={showSupportedVersions} onOpenChange={setShowSupportedVersions}>
<DialogContent className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto">
<DialogHeader className="pr-8">
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
</DialogHeader>
<PersonalWechatSupportedVersionsContent />
</DialogContent>
</Dialog>
<details className="personal-wechat-diagnostics">
<summary>高级诊断</summary>
<p>仅用于排查连接问题,普通使用无需关注这些信息。</p>
<dl>
{[
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
...(isMac
? [['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动']]
: []),
['绑定进程', diagnosticValue(senderStatus?.boundWechatPid)],
[
'接口监听',
`${diagnosticValue(senderStatus?.endpoint)} · ${senderStatus?.endpointReady ? '监听中' : '未监听'}`
],
[
'基址扫描',
senderStatus?.baseAddress || (senderStatus?.baseAddressReady ? '已完成' : '未完成')
],
['文字 Hook', senderStatus?.textHookReady ? '已就绪' : '未就绪'],
['图片 Hook', senderStatus?.imageHookReady ? '已就绪' : '未就绪'],
['语音能力', senderStatus?.canSendVoice ? '可发送' : '未就绪'],
['消息监听', senderStatus?.messageListenerReady ? '正常' : '未就绪'],
['微信版本', diagnosticValue(senderStatus?.wechatVersion)],
[
'运行时',
runtimeStatus?.version
? `${runtimeStatus.version} · ${runtimeStatus.state}`
: diagnosticValue(senderStatus?.runtimeReady)
]
].map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</details>
</section>
)
}
@@ -0,0 +1,46 @@
import type React from 'react'
export const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
export const BUNDLED_WECHAT_VERSIONS = [
'4.1.6.12',
'4.1.6.46',
'4.1.6.47',
'4.1.7.31',
'4.1.7.55',
'4.1.7.57',
'4.1.8.28',
'4.1.8.29',
'4.1.8.104',
'4.1.8.107',
'4.1.9.52',
'4.1.9.55',
'4.1.9.58',
'4.1.10.53',
'4.1.11.53'
] as const
export function PersonalWechatSupportedVersionsContent(): React.ReactElement {
return (
<>
<a
className="w-fit rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary hover:border-primary"
href={WECHAT_VERSION_DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
>
下载微信历史版本 ↗
</a>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{BUNDLED_WECHAT_VERSIONS.map((version) => (
<span
className="rounded-md border border-border bg-background px-2 py-2 text-center text-xs text-muted-foreground"
key={version}
>
{version}
</span>
))}
</div>
</>
)
}
@@ -6,8 +6,6 @@ import type {
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
return {
@@ -47,7 +45,7 @@ function statusLabel(status: PersonalWechatSenderStatus | null): string {
}
function statusText(value: string): string {
return value.replace(/Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
return value.replace(/OneBot|Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
}
function statusDescription(status: PersonalWechatSenderStatus | null): string {
@@ -66,17 +64,12 @@ export function PersonalWechatWindowsSendDialog({
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
const [detecting, setDetecting] = useState(true)
const [sendBusy, setSendBusy] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const [sendSuccess, setSendSuccess] = useState<string | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const requestIdRef = useRef(0)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closingRef = useRef(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const targetId = contact.m_nsUsrName
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
Boolean(initialImage)
)
const refreshStatus = useCallback(async (): Promise<void> => {
const requestId = ++requestIdRef.current
@@ -110,7 +103,9 @@ export function PersonalWechatWindowsSendDialog({
onOpenPersonalWechatSettings()
}
const handleSend = async (filePath: string): Promise<{ success: boolean; error?: string }> => {
const handleSend = async (
filePath: string
): Promise<{ success: boolean; error?: string }> => {
setSendBusy(true)
try {
const response = await window.api.sendGeneratedTtsVoice({
@@ -142,28 +137,15 @@ export function PersonalWechatWindowsSendDialog({
const handleSendReportImage = async (): Promise<void> => {
if (!initialImage || !status?.canSendImage || sendBusy) return
setSendBusy(true)
setSendError(null)
setSendSuccess(null)
try {
await persistPostfixText()
const response = await window.api.sendPersonalWechatMessage({
type: 'image',
to: targetId,
isGroup: isGroupChat,
filePath: initialImage.path,
postfixText
filePath: initialImage.path
} satisfies PersonalWechatSendRequest)
setStatus(response.status)
if (!response.success) {
setSendError(response.error || '日报图片发送失败')
return
}
if (response.postfixError) {
setSendError(`日报图片已发送,但${response.postfixError}`)
setSendSuccess('日报图片发送成功')
} else {
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
}
if (!response.success) return
setMessages((current) => [
...current,
{
@@ -174,8 +156,6 @@ export function PersonalWechatWindowsSendDialog({
outgoing: true
}
])
} catch (error) {
setSendError(error instanceof Error ? error.message : String(error))
} finally {
setSendBusy(false)
}
@@ -203,7 +183,7 @@ export function PersonalWechatWindowsSendDialog({
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
<div>
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
文字转语音
</DialogTitle>
</div>
@@ -221,7 +201,7 @@ export function PersonalWechatWindowsSendDialog({
</div>
<div className="personal-wechat-send-target">
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
<strong>{displayName}</strong>
<code>{targetId}</code>
</div>
@@ -266,7 +246,7 @@ export function PersonalWechatWindowsSendDialog({
key={message.id}
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
>
<span className="personal-wechat-message-kind">语音</span>
<span className="personal-wechat-message-kind">语音</span>
<span>{message.text || message.fileName}</span>
</div>
))}
@@ -275,16 +255,6 @@ export function PersonalWechatWindowsSendDialog({
{initialImage && status.canSendImage ? (
<section className="personal-wechat-composer" aria-label="日报图片发送">
<p>已准备日报图片:{initialImage.name}</p>
<ReportImagePostfixInput
value={postfixText}
onChange={setPostfixText}
onBlur={() =>
void persistPostfixText().catch((error) =>
setSendError(error instanceof Error ? error.message : '发送后置词保存失败')
)
}
disabled={sendBusy}
/>
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
{sendBusy ? '发送中…' : '发送日报图片'}
</Button>
@@ -301,16 +271,6 @@ export function PersonalWechatWindowsSendDialog({
busy={sendBusy}
/>
) : null}
{sendError ? (
<div className="personal-wechat-global-error" role="alert">
{sendError}
</div>
) : null}
{sendSuccess ? (
<div className="personal-wechat-global-success" role="status">
{sendSuccess}
</div>
) : null}
</>
) : null}
</DialogContent>
@@ -1,32 +0,0 @@
import { DEFAULT_REPORT_IMAGE_POSTFIX_TEXT } from '../../../../shared/personal-wechat'
export function ReportImagePostfixInput({
value,
onChange,
onBlur,
disabled
}: {
value: string
onChange: (value: string) => void
onBlur: () => void
disabled: boolean
}): React.ReactElement {
return (
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">发送后置词</span>
<input
aria-label="发送后置词"
className="h-9 rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary disabled:opacity-60"
value={value}
maxLength={200}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
placeholder={DEFAULT_REPORT_IMAGE_POSTFIX_TEXT}
/>
<span className="text-xs text-muted-foreground">
图片确认发送成功后,会发送一条文本消息;留空则只发图片。
</span>
</label>
)
}
+4 -4
View File
@@ -34,12 +34,12 @@ export function ExportIcon({ className }: IconProps): React.ReactElement {
)
}
export function StatsIcon({ className }: IconProps): React.ReactElement {
export function MoreIcon({ className }: IconProps): React.ReactElement {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5.5 19v-6" />
<path d="M12 19V5" />
<path d="M18.5 19v-9" />
<circle cx="5" cy="12" r="1.4" />
<circle cx="12" cy="12" r="1.4" />
<circle cx="19" cy="12" r="1.4" />
</svg>
)
}
@@ -1,36 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { DEFAULT_REPORT_IMAGE_POSTFIX_TEXT } from '../../../../shared/personal-wechat'
export function useReportImagePostfixSetting(enabled: boolean): {
postfixText: string
setPostfixText: (value: string) => void
persistPostfixText: () => Promise<void>
} {
const [postfixText, setPostfixText] = useState(DEFAULT_REPORT_IMAGE_POSTFIX_TEXT)
useEffect(() => {
if (!enabled) return
const getSettings = window.api.getSettings
if (typeof getSettings !== 'function') return
let active = true
void getSettings()
.then((result) => {
if (!active) return
setPostfixText(
String(result.settings.reportImagePostfixText ?? DEFAULT_REPORT_IMAGE_POSTFIX_TEXT)
)
})
.catch(() => undefined)
return () => {
active = false
}
}, [enabled])
const persistPostfixText = useCallback(async (): Promise<void> => {
const setSettings = window.api.setSettings
if (typeof setSettings !== 'function') return
await setSettings({ reportImagePostfixText: postfixText })
}, [postfixText])
return { postfixText, setPostfixText, persistPostfixText }
}
@@ -27,7 +27,6 @@ import {
buildContactSearchIndex,
filterContactSearchIndex
} from '../../../../shared/contact-search'
import { loadExportPreferences, saveExportPreferences } from './exportPreferences'
const ALL_CONTACT_TYPES: ExportContactType[] = ['group', 'user']
const contactTypeKey = (types: ExportContactType[] | undefined): string =>
@@ -45,8 +44,6 @@ export function ExportWorkspace({
onCancelExport
}: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null
const defaultNameMode = initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
const [savedPreferences] = useState(() => loadExportPreferences(defaultNameMode))
const runningAllTask = exportTasks.find(
(task) => task.scope === 'all' && task.status === 'running'
)
@@ -67,30 +64,24 @@ export function ExportWorkspace({
const contactSearchIndex = useMemo(() => buildContactSearchIndex(contacts), [contacts])
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>(() => savedPreferences.range)
const [startDate, setStartDate] = useState(() => savedPreferences.startDate)
const [endDate, setEndDate] = useState(() => savedPreferences.endDate)
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(
() => new Set(savedPreferences.selectedKinds)
)
const [nameMode, setNameMode] = useState<ExportNameMode>(() => savedPreferences.nameMode)
const [includeMedia, setIncludeMedia] = useState(() => savedPreferences.includeMedia)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(
() => savedPreferences.includeVoiceTranscripts
const [range, setRange] = useState<ExportRange>(() => (runningAllTask ? 'all' : 'today'))
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
const [nameMode, setNameMode] = useState<ExportNameMode>(
initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
)
const [includeMedia, setIncludeMedia] = useState(true)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
const [includeAvatars, setIncludeAvatars] = useState(() => savedPreferences.includeAvatars)
const [preferOriginal, setPreferOriginal] = useState(() => savedPreferences.preferOriginal)
const [fallbackThumbnail, setFallbackThumbnail] = useState(
() => savedPreferences.fallbackThumbnail
)
const [keepMissing, setKeepMissing] = useState(() => savedPreferences.keepMissing)
const [format, setFormat] = useState<ExportFormat>(
() => runningAllTask?.format || savedPreferences.format
)
const [zip, setZip] = useState(() => runningAllTask?.zip ?? savedPreferences.zip)
const [fileName, setFileName] = useState(() => savedPreferences.fileName)
const [outputDirectory, setOutputDirectory] = useState(() => savedPreferences.outputDirectory)
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(true)
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
const [fileName, setFileName] = useState('')
const [outputDirectory, setOutputDirectory] = useState('')
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
@@ -101,42 +92,6 @@ export function ExportWorkspace({
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const selectionLimit = 5
React.useEffect(() => {
saveExportPreferences({
format,
range,
startDate,
endDate,
selectedKinds: Array.from(selectedKinds) as ExportRequest['kinds'],
nameMode,
includeMedia,
includeVoiceTranscripts,
includeAvatars,
preferOriginal,
fallbackThumbnail,
keepMissing,
zip,
fileName,
outputDirectory
})
}, [
endDate,
fallbackThumbnail,
fileName,
format,
includeAvatars,
includeMedia,
includeVoiceTranscripts,
keepMissing,
nameMode,
outputDirectory,
preferOriginal,
range,
selectedKinds,
startDate,
zip
])
React.useEffect(() => {
if (selectedContacts.length > 0) return
const candidate = initialContact || contacts[0]
@@ -251,6 +206,7 @@ export function ExportWorkspace({
const handleSelectContact = (contact: Contact): void => {
if (exportAll) {
setExportAll(false)
setRange('today')
}
if (!selectionMode) {
setSelectedContacts([contact])
@@ -280,6 +236,7 @@ export function ExportWorkspace({
setExportAll(true)
setAllContactTypes([...ALL_CONTACT_TYPES])
setSelectionMode(false)
setRange('all')
setStatus('idle')
}
@@ -460,7 +417,6 @@ export function ExportWorkspace({
setSelectedKinds(new Set(['text']))
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
setIncludeMedia(true)
setIncludeVoiceTranscripts(true)
setIncludeAvatars(true)
setPreferOriginal(true)
setFallbackThumbnail(true)
@@ -468,7 +424,6 @@ export function ExportWorkspace({
setFormat('csv')
setZip(false)
setFileName('')
setOutputDirectory('')
setStatus('idle')
setJobId('')
setProgress(null)
@@ -537,7 +492,7 @@ export function ExportWorkspace({
selectionMode={selectionMode}
exportContactCount={exportContacts.length}
format={format}
range={exportAll ? 'all' : range}
range={range}
startDate={startDate}
endDate={endDate}
selectedKinds={selectedKinds}
@@ -1,115 +0,0 @@
import type { ExportFormat, ExportMessageKind, ExportNameMode } from '../../../../shared/export'
import type { ExportRange } from './exportTypes'
export interface ExportPreferences {
format: ExportFormat
range: ExportRange
startDate: string
endDate: string
selectedKinds: ExportMessageKind[]
nameMode: ExportNameMode
includeMedia: boolean
includeVoiceTranscripts: boolean
includeAvatars: boolean
preferOriginal: boolean
fallbackThumbnail: boolean
keepMissing: boolean
zip: boolean
fileName: string
outputDirectory: string
}
export const EXPORT_PREFERENCES_STORAGE_KEY = 'tracememo_export_preferences'
const formats: ExportFormat[] = ['html', 'csv', 'json', 'markdown']
const ranges: ExportRange[] = ['all', 'today', 'threeDays', 'sevenDays', 'custom']
const messageKinds: ExportMessageKind[] = [
'text',
'image',
'video',
'voice',
'sticker',
'file',
'share',
'location',
'system'
]
const nameModes: ExportNameMode[] = ['groupNickname', 'remark', 'wechatNickname']
export function loadExportPreferences(defaultNameMode: ExportNameMode): ExportPreferences {
const defaults: ExportPreferences = {
format: 'csv',
range: 'today',
startDate: '',
endDate: '',
selectedKinds: ['text'],
nameMode: defaultNameMode,
includeMedia: true,
includeVoiceTranscripts: true,
includeAvatars: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true,
zip: false,
fileName: '',
outputDirectory: ''
}
try {
const stored = window.localStorage.getItem(EXPORT_PREFERENCES_STORAGE_KEY)
if (!stored) return defaults
const value = JSON.parse(stored) as Partial<ExportPreferences>
return {
format: formats.includes(value.format as ExportFormat)
? (value.format as ExportFormat)
: defaults.format,
range: ranges.includes(value.range as ExportRange)
? (value.range as ExportRange)
: defaults.range,
startDate: typeof value.startDate === 'string' ? value.startDate : defaults.startDate,
endDate: typeof value.endDate === 'string' ? value.endDate : defaults.endDate,
selectedKinds: Array.isArray(value.selectedKinds)
? [
...new Set(
value.selectedKinds.filter((kind): kind is ExportMessageKind =>
messageKinds.includes(kind as ExportMessageKind)
)
)
]
: defaults.selectedKinds,
nameMode: nameModes.includes(value.nameMode as ExportNameMode)
? (value.nameMode as ExportNameMode)
: defaults.nameMode,
includeMedia:
typeof value.includeMedia === 'boolean' ? value.includeMedia : defaults.includeMedia,
includeVoiceTranscripts:
typeof value.includeVoiceTranscripts === 'boolean'
? value.includeVoiceTranscripts
: defaults.includeVoiceTranscripts,
includeAvatars:
typeof value.includeAvatars === 'boolean' ? value.includeAvatars : defaults.includeAvatars,
preferOriginal:
typeof value.preferOriginal === 'boolean' ? value.preferOriginal : defaults.preferOriginal,
fallbackThumbnail:
typeof value.fallbackThumbnail === 'boolean'
? value.fallbackThumbnail
: defaults.fallbackThumbnail,
keepMissing:
typeof value.keepMissing === 'boolean' ? value.keepMissing : defaults.keepMissing,
zip: typeof value.zip === 'boolean' ? value.zip : defaults.zip,
fileName: typeof value.fileName === 'string' ? value.fileName : defaults.fileName,
outputDirectory:
typeof value.outputDirectory === 'string' ? value.outputDirectory : defaults.outputDirectory
}
} catch {
return defaults
}
}
export function saveExportPreferences(preferences: ExportPreferences): void {
try {
window.localStorage.setItem(EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))
} catch {
// Export remains usable when browser storage is unavailable.
}
}
@@ -1,365 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type { Contact } from '../../../../shared/types'
import {
GROUP_STATS_RANGE_OPTIONS,
formatActiveMemberStats,
formatMemberLineName,
formatSilentMemberStats,
formatStatsDateTime,
resolveGroupStatsRangeStart,
type GroupMemberStatsResult,
type GroupStatsRangeKey
} from '../../../../shared/group-stats'
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '../ui'
interface GroupMemberStatsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
contact: Contact
/** 跳到「设置 · 本地索引」,让用户能把没追平的索引处理掉。 */
onOpenLocalIndexSettings?: () => void
}
type CopyTarget = 'none' | 'active' | 'silent'
/**
* 群发言统计面板(单群)。
*
* - **结果**由 main 的 `GroupStatsService` 给出,组件只展示,不自己拼业务文本;
* - **可复制的文本**由 `shared/group-stats` 的 formatter 生成,保证「界面上看到的」
* 与「复制出去的」不会各说各话;
* - **名单不截断**:数字与列表必须能对上,否则用户会以为统计漏了人。
* 列表靠 CSS 限高滚动,不靠丢数据。
*/
export function GroupMemberStatsDialog({
open,
onOpenChange,
contact,
onOpenLocalIndexSettings
}: GroupMemberStatsDialogProps): React.ReactElement {
const [rangeKey, setRangeKey] = useState<GroupStatsRangeKey>('30d')
const [customStart, setCustomStart] = useState('')
const [customEnd, setCustomEnd] = useState('')
const [result, setResult] = useState<GroupMemberStatsResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState<CopyTarget>('none')
const [notice, setNotice] = useState<string | null>(null)
/** 发送能力必须由 main 判定:renderer 侧的 platform 常量会误判 macOS Intel。 */
const [canSendText, setCanSendText] = useState(false)
const [sending, setSending] = useState(false)
const timeWindow = useMemo(() => {
if (rangeKey === 'custom') {
const start = customStart ? new Date(`${customStart}T00:00:00`).getTime() : NaN
const end = customEnd ? new Date(`${customEnd}T23:59:59.999`).getTime() : NaN
return { startTime: start, endTime: end }
}
const endTime = Date.now()
return { startTime: resolveGroupStatsRangeStart(rangeKey, endTime), endTime }
}, [rangeKey, customStart, customEnd])
const rangeReady = Number.isFinite(timeWindow.startTime) && Number.isFinite(timeWindow.endTime)
useEffect(() => {
if (!open || !rangeReady || !contact.md5) return
let disposed = false
setLoading(true)
setError(null)
window.api
.getGroupMemberStats({
userMd5: contact.md5,
startTime: timeWindow.startTime,
endTime: timeWindow.endTime
})
.then((data) => {
if (!disposed) setResult(data)
})
.catch((loadError: unknown) => {
if (!disposed) setError(loadError instanceof Error ? loadError.message : String(loadError))
})
.finally(() => {
if (!disposed) setLoading(false)
})
return () => {
disposed = true
}
}, [open, rangeReady, contact.md5, timeWindow.startTime, timeWindow.endTime])
useEffect(() => {
if (!open) return
let disposed = false
window.api
.getPersonalWechatSenderStatus()
.then((status) => {
if (!disposed) setCanSendText(status?.canSendText === true)
})
.catch(() => {
if (!disposed) setCanSendText(false)
})
return () => {
disposed = true
}
}, [open])
const silentText = useMemo(() => (result ? formatSilentMemberStats(result) : ''), [result])
const activeText = useMemo(() => (result ? formatActiveMemberStats(result) : ''), [result])
const copy = useCallback(async (text: string, target: CopyTarget) => {
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(target)
setNotice(null)
setTimeout(() => setCopied('none'), 2000)
} catch {
setError('复制失败,请手动选择文本')
}
}, [])
const send = useCallback(
async (text: string) => {
if (!text || sending) return
setSending(true)
setNotice(null)
try {
await window.api.sendPersonalWechatMessage({
to: contact.m_nsUsrName,
isGroup: true,
type: 'text',
text
})
setNotice('已发送到当前群')
} catch (sendError: unknown) {
setError(sendError instanceof Error ? sendError.message : '发送失败')
} finally {
setSending(false)
}
},
[contact.m_nsUsrName, sending]
)
const stale = result !== null && result.freshness !== 'fresh'
const groupTitle = contact.m_nsNickName || contact.m_nsUsrName
/** 备注与群名相同时不重复展示,避免标题里出现两遍同一个人。 */
const groupRemark = contact.remark && contact.remark !== groupTitle ? contact.remark : ''
/** 选「全部」时 startTime 为 0,改用本机第一条消息的真实时间作为起点。 */
const rangeStart = result
? result.startTime > 0
? result.startTime
: result.firstMessageTime
: null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>群发言统计 · {groupTitle}</DialogTitle>
{groupRemark ? (
<p className="group-stats-subtitle">群备注:{groupRemark}</p>
) : null}
</DialogHeader>
<div className="group-stats-panel">
<div className="group-stats-ranges">
{GROUP_STATS_RANGE_OPTIONS.map((option) => (
<Button
key={option.key}
variant={rangeKey === option.key ? 'default' : 'outline'}
size="sm"
onClick={() => setRangeKey(option.key)}
>
{option.label}
</Button>
))}
{rangeKey === 'custom' ? (
<div className="group-stats-custom-range">
<input
type="date"
aria-label="开始日期"
value={customStart}
onChange={(event) => setCustomStart(event.target.value)}
/>
<span>至</span>
<input
type="date"
aria-label="结束日期"
value={customEnd}
onChange={(event) => setCustomEnd(event.target.value)}
/>
</div>
) : null}
</div>
{!rangeReady ? (
<p className="group-stats-hint">请选择完整的开始与结束日期。</p>
) : null}
{stale ? (
<div className="group-stats-stale">
<p role="alert" className="group-stats-warning">
本地索引尚未完全同步,以下结果可能不完整。
</p>
{onOpenLocalIndexSettings ? (
<Button
variant="outline"
size="sm"
onClick={() => {
// 先关面板再跳转:否则设置页会被这个 Dialog 挡在后面。
onOpenChange(false)
onOpenLocalIndexSettings()
}}
>
去建立索引
</Button>
) : null}
</div>
) : null}
{error ? (
<p role="alert" className="group-stats-error">
{error}
</p>
) : null}
{notice ? <p className="group-stats-notice">{notice}</p> : null}
{loading ? <p className="group-stats-hint">统计中…</p> : null}
{result && !loading ? (
<>
<div className="group-stats-overview">
<div className="group-stats-metric">
<div className="group-stats-metric-label">当前群成员</div>
<div className="group-stats-metric-value">{result.memberCount}</div>
</div>
<div className="group-stats-metric">
<div className="group-stats-metric-label">发过言</div>
<div className="group-stats-metric-value">{result.activeMemberCount}</div>
</div>
<div className="group-stats-metric">
<div className="group-stats-metric-label">没发言</div>
<div className="group-stats-metric-value">{result.silentMemberCount}</div>
</div>
</div>
<p className="group-stats-range-text">
统计区间:
{rangeStart
? `${formatStatsDateTime(rangeStart)} ~ ${formatStatsDateTime(result.endTime)}`
: `全部历史 ~ ${formatStatsDateTime(result.endTime)}`}
{result.startTime <= 0 && rangeStart ? '(本机该群第一条消息起)' : null}
</p>
{/* 两个名单用 tab 分开:一次只渲染一个列表,滚动压力减半,
而且「复制/发送」跟它作用的内容处在同一个 tab 里,不会看错对象。 */}
<Tabs defaultValue="active" className="group-stats-tabs">
<TabsList className="group-stats-tabs-list">
<TabsTrigger value="active">
发言排行({result.activeMemberCount})
</TabsTrigger>
<TabsTrigger value="silent">
未发言统计({result.silentMemberCount})
</TabsTrigger>
</TabsList>
<TabsContent value="active">
<div className="group-stats-section">
<div className="group-stats-section-head">
<h3>
发言成员排行
<span className="group-stats-section-count">
(共 {result.activeMemberCount} 人)
</span>
</h3>
<div className="group-stats-section-actions">
<Button
variant="outline"
size="sm"
onClick={() => void copy(activeText, 'active')}
>
{copied === 'active' ? '已复制' : '复制'}
</Button>
</div>
</div>
{result.activeMembers.length === 0 ? (
<p className="group-stats-empty">该区间内没有人发言。</p>
) : (
<ol className="group-stats-list">
{result.activeMembers.map((member, index) => (
<li key={member.senderId} className="group-stats-row">
<span className="group-stats-row-name">
<span className="group-stats-row-index">{index + 1}.</span>
{formatMemberLineName(member)}
</span>
<span className="group-stats-row-meta">
{member.messageCount} 条 ·{' '}
{formatStatsDateTime(member.lastMessageTime)}
</span>
</li>
))}
</ol>
)}
</div>
</TabsContent>
<TabsContent value="silent">
<div className="group-stats-section">
<div className="group-stats-section-head">
<h3>
未发言成员
<span className="group-stats-section-count">
(共 {result.silentMemberCount} 人)
</span>
</h3>
<div className="group-stats-section-actions">
<Button
variant="outline"
size="sm"
onClick={() => void copy(silentText, 'silent')}
>
{copied === 'silent' ? '已复制' : '复制'}
</Button>
{canSendText ? (
<Button
variant="default"
size="sm"
disabled={sending}
onClick={() => void send(silentText)}
>
{sending ? '发送中…' : '发送到当前群'}
</Button>
) : null}
</div>
</div>
{result.silentMembers.length === 0 ? (
<p className="group-stats-empty">全部成员在该区间内都发过言。</p>
) : (
<ol className="group-stats-list">
{result.silentMembers.map((member, index) => (
<li key={member.senderId} className="group-stats-row">
<span className="group-stats-row-name">
<span className="group-stats-row-index">{index + 1}.</span>
{formatMemberLineName(member)}
</span>
</li>
))}
</ol>
)}
</div>
</TabsContent>
</Tabs>
<p className="group-stats-notes">
{result.unattributedMessages > 0
? `另有 ${result.unattributedMessages} 条消息无法归属到具体成员(未计入任何人)。`
: null}
{result.excludedSystemMessages > 0
? `已排除 ${result.excludedSystemMessages} 条系统消息。`
: null}
{result.limitations.join(' ')}
</p>
</>
) : null}
</div>
</DialogContent>
</Dialog>
)
}
@@ -54,13 +54,6 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
<path d="M5.5 15.5v3h13v-3" />
</svg>
)
case 'automation':
// 闪电:强调「命中即自动执行」,与退群监控的箭头区分开。
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M13 4.5 7.5 13h4l-.5 6.5L16.5 11h-4z" />
</svg>
)
case 'agent-hub':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -3,7 +3,6 @@ export type AppPage =
| 'search'
| 'report'
| 'exit-monitor'
| 'automation'
| 'agent-hub'
| 'export'
| 'api'
@@ -19,7 +18,6 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'search', label: '问问微信' },
{ id: 'report', label: '日报' },
{ id: 'exit-monitor', label: '退群监控' },
{ id: 'automation', label: '自动化' },
{ id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
@@ -4,13 +4,7 @@ import { Checkbox } from '../ui'
interface MessageTypeSelectorProps {
value: SummaryMessageType[]
/**
* 每类消息的条数。
*
* 可选:`自动化 → 定时日报` 的编辑器没有真实消息统计,
* 不传就不显示数字,而不是显示一排假的 0。
*/
counts?: Record<SummaryMessageType, number>
counts: Record<SummaryMessageType, number>
disabled: boolean
onChange: (value: SummaryMessageType[]) => void
}
@@ -55,7 +49,7 @@ export function MessageTypeSelector({
<b>{option.label}</b>
<small>{option.description}</small>
</span>
{counts ? <em>{counts[option.value]}</em> : null}
<em>{counts[option.value]}</em>
</label>
))}
</div>
File diff suppressed because it is too large Load Diff
@@ -1,342 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Progress,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../ui'
import {
describeImageTextCoverage,
imageTextCoverageState,
imageTextPhaseLabel,
imageTextProcessedPercent,
imageTextStateLabel,
imageTextStateTone
} from '../../../../shared/image-text-index'
import { useImageTextIndexStatus } from './hooks/useImageTextIndexStatus'
import {
IndexStatusBadge,
type IndexStatusTone
} from '../../features/settings/components/IndexStatusBadge'
interface ImageTextIndexSummaryProps {
dbReady: boolean
onNotice: (message: string) => void
}
/**
* 「图片文字索引」卡片(**设置页宽版**)。
*
* 为什么不是直接复用 `ImageTextIndexCard`:那个组件是给**问问微信侧栏**写的,
* 而侧栏最窄时正文只有约 145px(见 `search.scss` 的注释)。
* 四列 metric 布局塞进 145px 会直接崩掉 —— 反过来让侧栏继续用表格布局又浪费设置页的宽度。
* 所以两者**共享全部数据来源与文案函数**(`useImageTextIndexStatus` +
* `shared/image-text-index`),只有**版式**不同。
*
* 状态徽章、统计数字、进度条都走公共组件/函数,不存在第二套计算口径。
*/
export function ImageTextIndexSummary({
dbReady,
onNotice
}: ImageTextIndexSummaryProps): React.ReactElement {
const {
status,
count,
counting,
pending,
running,
paused,
established,
refreshCount,
start,
pause,
resume,
cancel,
resetFailures,
repair
} = useImageTextIndexStatus({ dbReady, onNotice })
const [confirming, setConfirming] = useState(false)
const [confirmCount, setConfirmCount] = useState<number | null>(null)
/**
* 处理范围(天)。`0` = 全部历史。
*
* 保留这个选择器是因为它来自真实业务能力,不是装饰:全量回填几万张图没法用来排查问题,
* 先跑「最近 1 天」才能证明链路真的通了。
*/
const [rangeDays, setRangeDays] = useState('0')
const sinceMs = useMemo(() => {
const days = Number(rangeDays)
return Number.isFinite(days) && days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : undefined
}, [rangeDays])
useEffect(() => {
if (!dbReady) return
void refreshCount(sinceMs)
}, [dbReady, sinceMs, refreshCount])
const coverage = status?.coverage ?? null
const progress = status?.progress ?? null
const coverageState = coverage ? imageTextCoverageState(coverage) : 'not_built'
const percent = coverage
? imageTextProcessedPercent(coverage.processed, coverage.totalImageMessages)
: 0
const interrupted = paused || progress?.state === 'cancelled'
const phaseLabel = progress?.currentPhase ? imageTextPhaseLabel(progress.currentPhase) : null
const labelInput = {
progressState: progress?.state,
running,
paused,
established,
coverageState,
percent
}
const stateLabel = imageTextStateLabel(labelInput)
const tone = imageTextStateTone(labelInput) as IndexStatusTone
const detectedImages = count?.totalImageMessages ?? coverage?.totalImageMessages ?? null
const nothingCounted =
count !== null && count.scannedConversations === 0 && count.failedConversations > 0
const failureCount = coverage?.failed ?? 0
const requestStart = async (): Promise<void> => {
const fresh = await refreshCount(sinceMs)
setConfirmCount(fresh?.totalImageMessages ?? detectedImages)
setConfirming(true)
}
return (
<section className="local-index-card" aria-label="图片文字索引状态">
<header className="local-index-card-head">
<div className="local-index-card-title">
<h3>图片文字索引</h3>
<p>本地 OCR 提取图片文字,让截图、报价图也能被搜到</p>
</div>
<IndexStatusBadge label={stateLabel} tone={tone} />
</header>
{/* 未建立:先给一个可核对的图片量,再让用户决定跑不跑 */}
{!established && !running ? (
<>
<div className="local-index-stats">
<div className="local-index-stat">
<strong className="local-index-stat-value">
{counting
? '统计中…'
: nothingCounted
? '无法统计'
: detectedImages === null
? '—'
: detectedImages.toLocaleString()}
</strong>
<span className="local-index-stat-label">检测到图片消息</span>
</div>
</div>
<div className="local-index-inline">
<span className="local-index-stat-label">处理范围</span>
<Select value={rangeDays} onValueChange={setRangeDays}>
<SelectTrigger className="h-7 w-[110px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">全部历史</SelectItem>
<SelectItem value="1">近 1 天</SelectItem>
<SelectItem value="7">近 7 天</SelectItem>
<SelectItem value="30">近 30 天</SelectItem>
</SelectContent>
</Select>
</div>
{nothingCounted ? (
<p className="local-index-note is-error">
{`无法统计本账号的图片消息(${count?.error || '读取消息表失败'})。这不代表没有图片,可以重新统计再试一次。`}
</p>
) : null}
</>
) : null}
{/* 运行中 / 暂停:原地切进度,按钮按真实状态显隐,不允许重复点 */}
{(running || paused) && progress ? (
<div className="local-index-progress">
<div className="local-index-progress-head">
<span>识别图片文字</span>
<span className="local-index-progress-count">
{progress.processed.toLocaleString()} / {progress.totalImageMessages.toLocaleString()}
</span>
</div>
{/* 复用项目 `Progress`(更新页 / 导出任务中心同一组件),不另起一套轨道样式。 */}
<Progress
value={Math.min(100, Math.max(0, progress.percent))}
aria-label="图片文字索引进度"
/>
<p className="local-index-note">
{phaseLabel ?? '正在识别…'}
{` · 识别出文字 ${progress.indexed.toLocaleString()} · 无文字 ${progress.empty.toLocaleString()}`}
</p>
</div>
) : null}
{/* 已建立:四个可核对的数字 */}
{established && !running && !paused && coverage ? (
<>
<div className="local-index-stats is-four">
<div className="local-index-stat">
<strong className="local-index-stat-value">
{coverage.indexed.toLocaleString()}
</strong>
<span className="local-index-stat-label">已识别文字</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">{coverage.empty.toLocaleString()}</strong>
<span className="local-index-stat-label">无文字图片</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">
{coverage.missing.toLocaleString()}
</strong>
<span className="local-index-stat-label">图片已清理</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">{coverage.failed.toLocaleString()}</strong>
<span className="local-index-stat-label">识别失败</span>
</div>
</div>
</>
) : null}
{progress?.state === 'error' ? (
<p className="local-index-note is-error">
{progress.lastError || '图片文字索引建立失败,可以稍后重试。'}
</p>
) : null}
{coverage?.systemicFailure === true ? (
<p className="local-index-note is-error">
{`${(coverage.failed ?? 0).toLocaleString()} 条处理失败,成功识别 0 条 —— 当前无法搜索图片中的文字。`}
</p>
) : null}
{paused ? (
<p className="local-index-note">已暂停。已识别的结果都保留了,点「继续」会从断点接着做。</p>
) : null}
{!dbReady ? (
<p className="local-index-note is-error">请先连接微信数据,然后再建立图片文字索引。</p>
) : null}
{/* footer:左侧覆盖量说明,右侧操作。按钮不再悬浮在 stats 右下角。 */}
<div className="local-index-footer">
{established && !running && !paused && coverage ? (
<p className="local-index-note">{describeImageTextCoverage(coverage)}</p>
) : null}
<div className="local-index-actions">
{running ? (
<>
<Button
size="sm"
variant="outline"
disabled={pending !== null}
onClick={() => void pause()}
>
{pending === 'pause' ? '暂停中…' : '暂停'}
</Button>
<Button
size="sm"
variant="outline"
disabled={pending !== null}
onClick={() => void cancel()}
>
{pending === 'cancel' ? '取消中…' : '取消'}
</Button>
</>
) : null}
{/* 中断过就一定要有「继续」:checkpoint 是保留的,续做不会从头开始。 */}
{interrupted ? (
<Button size="sm" disabled={pending !== null} onClick={() => void resume()}>
{pending === 'resume' ? '继续中…' : '继续'}
</Button>
) : null}
{!running && !interrupted ? (
<>
{/* 修复类操作各自只在很窄的场景有用,收进「更多」;
平铺出来会让用户面对四个都带「索引」字样的按钮。 */}
{established || failureCount > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
aria-label="更多操作"
disabled={pending !== null}
>
···
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{established ? (
<DropdownMenuItem disabled={pending !== null} onSelect={() => void repair()}>
图片内容搜不到?修复搜索索引
</DropdownMenuItem>
) : null}
{failureCount > 0 ? (
<DropdownMenuItem
disabled={pending !== null}
onSelect={() => void resetFailures()}
>
{`重试识别失败的图片(${failureCount.toLocaleString()} 张)`}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
size="sm"
disabled={!dbReady || pending !== null || counting}
onClick={() => void requestStart()}
>
{/* 统计图片数期间按钮是禁用的;这时如实说"统计中",
而不是灰着一个写着「更新索引」却点不动的按钮。 */}
{counting ? '统计中…' : established ? '更新索引' : '建立图片文字索引'}
</Button>
</>
) : null}
</div>
</div>
{/* 确认弹窗复用 `confirming` 状态;保持与侧栏卡片一致的二次确认语义。 */}
{confirming ? (
<div className="local-index-confirm" role="dialog" aria-label="建立图片文字索引">
<p>
当前账号检测到约{' '}
<strong>
{confirmCount === null ? '未知数量' : confirmCount.toLocaleString()} 条图片消息
</strong>
。
</p>
<p>
识别仅在本机进行,原始图片不会因为本地识别而自动上传;可能需要较长时间,可以暂停稍后继续。
</p>
<div className="local-index-actions">
<Button size="sm" variant="outline" onClick={() => setConfirming(false)}>
取消
</Button>
<Button
size="sm"
onClick={() => {
setConfirming(false)
void start({ ...(sinceMs ? { sinceMs } : {}) })
}}
>
开始索引
</Button>
</div>
</div>
) : null}
</section>
)
}
@@ -1,142 +0,0 @@
import React from 'react'
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
import {
formatBytes,
formatIndexDate,
formatKnowledgeProcessed,
knowledgeIsStale,
knowledgeStateLabel
} from './searchFormatters'
import {
IndexStatusBadge,
type IndexStatusTone
} from '../../features/settings/components/IndexStatusBadge'
import { Button } from '../ui'
interface KnowledgeIndexCardProps {
dbReady: boolean
onNotice: (message: string) => void
}
/**
* 「聊天记录索引」卡片 —— 问问微信背后那份索引。
*
* 布局与服务端语义分得很开:
* - 数据全部来自 `useKnowledgeStatus`(与问问微信页**同一个 hook**,不存在第二套状态源);
* - 文案全部来自 `searchFormatters`(那边的词汇表有约束:区分「正在追新」与「正在补齐历史」、
* 不可查询时吃掉「可用」前缀、禁止笼统的「已同步」);
* - 这里只负责**信息层级**:标题 → 状态徽章 → 三个核心数字 → 操作。
*
* 状态刻意从标题里拆出来单独做成徽章:混在标题行里时既没有视觉权重差、也扫不到。
*/
export function KnowledgeIndexCard({
dbReady,
onNotice
}: KnowledgeIndexCardProps): React.ReactElement {
const {
knowledgeStatus,
knowledgeSyncing,
syncStarting,
cancelRequested,
startKnowledgeSync,
cancelKnowledgeSync
} = useKnowledgeStatus({ dbReady, onNotice })
const indexed = knowledgeStatus?.indexedMessageCount ?? 0
const usable = indexed > 0 || (knowledgeStatus?.indexedChunkCount ?? 0) > 0
const stale = knowledgeIsStale(knowledgeStatus)
const failed = knowledgeStatus?.state === 'error'
/** 徽章色只用主题 token,不硬编码彩虹色。 */
const tone: IndexStatusTone = failed
? 'error'
: !usable
? 'idle'
: stale || knowledgeSyncing
? 'warn'
: 'ok'
const cancellable = knowledgeStatus?.pass?.cancellable === true
return (
<section className="local-index-card" aria-label="聊天记录索引状态">
<header className="local-index-card-head">
<div className="local-index-card-title">
<h3>聊天记录索引</h3>
<p>用于搜索、问问微信和群聊统计</p>
</div>
<IndexStatusBadge label={knowledgeStateLabel(knowledgeStatus)} tone={tone} />
</header>
{usable ? (
<div className="local-index-stats">
<div className="local-index-stat">
<strong className="local-index-stat-value">{indexed.toLocaleString()}</strong>
<span className="local-index-stat-label">已收录消息</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">
{formatBytes(knowledgeStatus?.databaseBytes ?? 0)}
</strong>
<span className="local-index-stat-label">占用空间</span>
</div>
<div className="local-index-stat">
<strong className="local-index-stat-value">
{knowledgeStatus?.indexLatestAt
? formatIndexDate(knowledgeStatus.indexLatestAt)
: '—'}
</strong>
<span className="local-index-stat-label">最后更新</span>
</div>
</div>
) : (
<p className="local-index-note">
还没有建立索引。建立后「问问微信」才能跨会话检索历史消息。
</p>
)}
{failed && knowledgeStatus?.lastError ? (
<p className="local-index-note is-error">{knowledgeStatus.lastError}</p>
) : null}
{!dbReady ? (
<p className="local-index-note is-error">请先连接微信数据,然后再建立聊天记录索引。</p>
) : null}
{/* footer:左侧状态说明,右侧操作。按钮不再悬浮在 stats 右下角。 */}
<div className="local-index-footer">
<p className="local-index-note">
{knowledgeSyncing && knowledgeStatus?.pass
? formatKnowledgeProcessed(knowledgeStatus)
: failed
? '上次同步没有完成,可以重新同步。'
: stale
? '有较新的聊天记录尚未入索引,点「同步最新记录」追平。'
: '索引数据仅保存在本机,可随时重新建立。'}
</p>
<div className="local-index-actions">
{knowledgeSyncing && cancellable ? (
<Button
size="sm"
variant="outline"
disabled={cancelRequested}
onClick={() => void cancelKnowledgeSync()}
>
{cancelRequested ? '正在取消…' : '取消同步'}
</Button>
) : null}
{/* 用项目 `Button` 而不是自写 `<button>` + 自己的主色样式:
`hsl(var(--tm-primary))` 在这套主题变量下不生效(实测背景回退成了浏览器默认灰),
而复用组件同时还省掉了一套与全站不一致的按钮外观。 */}
<Button
size="sm"
disabled={!dbReady || syncStarting || (knowledgeSyncing && !cancellable)}
onClick={() => void startKnowledgeSync()}
>
{knowledgeSyncing ? '同步中…' : usable ? '同步最新记录' : '建立索引'}
</Button>
</div>
</div>
</section>
)
}
@@ -214,11 +214,11 @@ export function AgentHubWorkspace({
<ul>
<li>
<i />
在主进程内运行,不开放本地端口
本机 HTTP 通信
</li>
<li>
<i />
对话记录可回看
入站请求鉴权
</li>
<li>
<i />
@@ -58,67 +58,6 @@ 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' | 'PATCH'
export type ApiMethod = 'GET' | 'POST'
export type { ApiTokenStatus } from '../../../../../shared/local-api-auth'
export interface ApiParameter {
@@ -4,19 +4,8 @@ export function buildApiUrl(
path: string,
params: Record<string, string>
): string {
let resolvedPath = path
const pathParameterKeys = new Set(
Object.keys(params).filter((key) => path.includes(`{${key}}`))
)
const url = new URL(path, `http://${host}:${port}`)
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()
@@ -1,57 +0,0 @@
import * as React from 'react'
import { SegmentedControl, SegmentedControlItem } from '../../components/ui'
import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../../shared/automation'
/**
* Tab 上能出现的规则类型。
*
* `scheduled_report` 是真实的 `AutomationRuleType`;这个别名只为让调用点的
* 语义更清楚 —— 它等价于 `AutomationRuleType`。
*/
export type AutomationRuleTabType = AutomationRuleType
/**
* Tab 顺序 = 触发方式的演进:消息触发 → 时间触发 → 系统事件触发。
*/
const RULE_TABS: AutomationRuleTabType[] = [
'daily_report',
'scheduled_report',
'leave_notification'
]
const TAB_LABELS: Record<AutomationRuleTabType, string> = AUTOMATION_RULE_TYPE_LABELS
/**
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
*
* 它切的是"现在在编辑哪一类规则",**不是**"把当前这条规则改成另一类"。
* 文案必须说清这一点:叫「规则类型」时,用户会以为它是当前规则的类型选择器,
* 于是"切一下再保存"就意外改到了另一条规则上。
*
* 两项都是**真规则**:切换只改前端选中态,不碰任何已保存的配置。
*/
export function AutomationRuleTypeTabs({
value,
onValueChange
}: {
value: AutomationRuleTabType
onValueChange: (next: AutomationRuleTabType) => void
}): React.ReactElement {
return (
<div className="automation-rule-type-tabs">
<span className="automation-rule-type-label">切换规则类型</span>
<SegmentedControl
value={value}
onValueChange={(next) => onValueChange(next as AutomationRuleTabType)}
aria-label="切换规则类型"
>
{RULE_TABS.map((type) => (
<SegmentedControlItem key={type} value={type}>
{TAB_LABELS[type]}
</SegmentedControlItem>
))}
</SegmentedControl>
</div>
)
}
@@ -1,798 +0,0 @@
import * as React from 'react'
import {
BUILTIN_DAILY_REPORT_RULE_ID,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
calculateNextRunAt,
normalizeScheduledReportConfig,
type AutomationExecution,
type AutomationRule,
type AutomationRuleDraft,
type AutomationStatusSummary
} from '../../../../shared/automation'
import {
Button,
SegmentedControl,
SegmentedControlItem,
Spinner,
useToast
} from '../../components/ui'
import { RuleListPanel } from './RuleListPanel'
import { RuleEditorPanel } from './RuleEditorPanel'
import { ExecutionLogPanel } from './ExecutionLogPanel'
import { ExecutionDetailDrawer } from './ExecutionDetailDrawer'
import {
AutomationRuleTypeTabs,
type AutomationRuleTabType
} from './AutomationRuleTypeTabs'
import { ScheduledReportEditor } from './ScheduledReportEditor'
import { ScheduledReportRuleList } from './ScheduledReportRuleList'
import { ScheduledReportNotificationPanel } from './ScheduledReportNotificationPanel'
import {
createDraftFromRule,
createEmptyScheduledReportDraft,
draftToAutomationRuleDraft,
formatNextRunAt,
resolveGroupDisplayName,
type ScheduledReportDraft
} from './model/scheduled-report-model'
import {
LeaveNotificationEditor,
type LeaveNotificationSaveInput
} from './LeaveNotificationEditor'
import type { LeaveNotificationContactOption } from './LeaveNotificationTargetPicker'
import { UNAVAILABLE_STATUS, automationApi, isAutomationApiAvailable, type AutomationGroupOption } from './model/api'
/**
* AutomationWorkspace —— 自动化一级菜单。
*
* 两个 tab:规则 / 执行日志。顶部三块状态(监听 / 发送能力 / 今日执行)
* **全部来自 main 的真实能力**,渲染层不做任何平台猜测 ——
* 粗平台判断在这里是错的:装了 macOS 但没绑定个人微信,照样发不出去。
*
* 「规则」内部再分一层**规则类型**:@我生成日报 / 退群通知。
* 两种类型现在都是**真实规则**,保存都会落盘。
*/
type AutomationTab = 'rules' | 'logs'
/** 由「退群监控」深链过来时的请求:直接打开指定规则类型。 */
export interface AutomationOpenRuleRequest {
ruleType: AutomationRuleTabType
/** 每次点击都要能重新触发,所以带一个自增/时间戳。 */
requestId: number
}
export interface AutomationWorkspaceProps {
dbReady: boolean
/** 发送能力不可用时,引导用户去设置页。 */
onOpenSendSettings?: () => void
/** 「管理监控群聊 →」:跳到退群监控的管理群聊页(纯导航)。 */
onOpenExitMonitorGroups?: () => void
/** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */
onOpenModelSettings?: () => void
/** 「微信异常通知」依赖 Agent Hub:未就绪时给一个可操作的去处。 */
onOpenAgentHub?: () => void
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
openRuleRequest?: AutomationOpenRuleRequest | null
}
interface EditorState {
mode: 'create' | 'edit'
rule: AutomationRule | null
/** 用 Tab 类型:定时日报只在本层存在,不进真实 `AutomationRule`。 */
ruleType: AutomationRuleTabType
}
export function AutomationWorkspace({
dbReady,
onOpenSendSettings,
onOpenExitMonitorGroups,
onOpenModelSettings,
onOpenAgentHub,
openRuleRequest
}: AutomationWorkspaceProps): React.ReactElement {
const { toast } = useToast()
const apiAvailable = React.useMemo(() => isAutomationApiAvailable(), [])
const [tab, setTab] = React.useState<AutomationTab>('rules')
const [status, setStatus] = React.useState<AutomationStatusSummary>(UNAVAILABLE_STATUS)
const [rules, setRules] = React.useState<AutomationRule[]>([])
const [groups, setGroups] = React.useState<AutomationGroupOption[]>([])
const [executions, setExecutions] = React.useState<AutomationExecution[]>([])
const [sendableContacts, setSendableContacts] = React.useState<LeaveNotificationContactOption[]>([])
const [monitoredGroupCount, setMonitoredGroupCount] = React.useState(0)
/** 已监控群聊清单(含显示名)—— 退群通知「通知群聊」二次勾选的候选项。 */
const [monitoredGroups, setMonitoredGroups] = React.useState<AutomationGroupOption[]>([])
const [loading, setLoading] = React.useState(true)
const [clearing, setClearing] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const [busyRuleId, setBusyRuleId] = React.useState<string | null>(null)
const [editor, setEditor] = React.useState<EditorState | null>(null)
const [selectedExecution, setSelectedExecution] = React.useState<AutomationExecution | null>(null)
const [pendingDelete, setPendingDelete] = React.useState<AutomationRule | null>(null)
const reload = React.useCallback(async (): Promise<void> => {
const [
nextStatus,
nextRules,
nextGroups,
nextExecutions,
nextContacts,
nextMonitored,
nextMonitoredGroups
] = await Promise.all([
automationApi.getStatus(),
automationApi.listRules(),
automationApi.listGroups(),
automationApi.listExecutions(100),
// 「指定好友」候选、已监控群聊数量与清单:都来自既有能力,不在自动化里另存一份。
automationApi.listSendableContacts(),
automationApi.getMonitoredGroupCount(),
automationApi.getMonitoredGroups()
])
setStatus(nextStatus)
setRules(nextRules)
setGroups(nextGroups)
setExecutions(nextExecutions)
setSendableContacts(nextContacts)
setMonitoredGroupCount(nextMonitored)
setMonitoredGroups(nextMonitoredGroups)
}, [])
React.useEffect(() => {
let disposed = false
setLoading(true)
void reload().finally(() => {
if (!disposed) setLoading(false)
})
return () => {
disposed = true
}
}, [reload])
// 切到日志 tab 时刷新一次:规则跑完后台不会主动推事件给渲染层,
// 用户点进来看到的是上一次加载的快照才是真的误导。
React.useEffect(() => {
if (tab !== 'logs') return
void reload()
}, [tab, reload])
/**
* 「退群监控 → 退群通知自动化」的深链。
*
* 落到「规则 → 退群通知」这一层,而不是只跳到自动化首页 ——
* 否则用户还得自己再找一次。请求对象本身在父层是稳定的,
* 所以依赖整个对象;requestId 变化就代表又点了一次。
*/
React.useEffect(() => {
if (!openRuleRequest) return
setTab('rules')
setEditor({ mode: 'edit', rule: null, ruleType: openRuleRequest.ruleType })
}, [openRuleRequest])
/**
* 当前编辑目标(日报)—— **UI 显示与保存共用这一个**。
*
* 这里保留"回落到内置日报"的兜底,但它同时驱动下面的「正在编辑:X」,
* 所以即使回落发生,用户也能看到真实目标,不会再出现"以为在改 A、实际改了 B"。
*/
const dailyReportTarget =
editor?.ruleType === 'daily_report'
? (editor.rule ?? rules.find((item) => item.id === BUILTIN_DAILY_REPORT_RULE_ID) ?? null)
: null
/** 退群通知是 singleton 规则,永远从规则表里取(不存在则由 main 侧播种)。 */
const leaveNotificationRule =
rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) ?? null
/** 「指定好友」目标的显示名(首页卡片用;拿不到时留空,不伪造)。 */
const leaveContactName = React.useMemo((): string => {
const target = leaveNotificationRule?.leaveNotification?.target
if (!target || target.type !== 'contact') return ''
return sendableContacts.find((contact) => contact.id === target.contactId)?.name ?? ''
}, [leaveNotificationRule, sendableContacts])
/**
* 记住"上一次在日报这一类里编辑的那条"。
*
* 切类型只是换一类规则来编辑,切回来时应当回到刚才那条 ——
* 否则用户从"某条日报"切走再切回,会被悄悄带到内置日报上。
*/
const lastDailyTargetRef = React.useRef<AutomationRule | null>(null)
/** 切到「@我生成日报」时的编辑目标:上次那条 → 内置日报 → 新建。 */
const resolveDailyTarget = React.useCallback((): AutomationRule | null => {
return (
lastDailyTargetRef.current ??
rules.find((item) => item.id === BUILTIN_DAILY_REPORT_RULE_ID) ??
null
)
}, [rules])
/*
* ---------- 定时日报(真实规则,真写入)----------
*
* `scheduled_report` 是真实的 `AutomationRuleType`,所以这一层
* **不做任何数据副本**:列表 = `rules.filter(ruleType==='scheduled_report')`,
* 保存 / 启停 / 删除 / 立即执行全部经 `automation:*` IPC 落到
* `AutomationRuleStore` 与 `AutomationService`。
*/
const scheduledRules = React.useMemo(
() => rules.filter((rule) => rule.ruleType === 'scheduled_report'),
[rules]
)
/** `null` = 看列表;非空 = 在编辑器里(`ruleId` 为 `null` 表示新建)。 */
const [scheduledEditor, setScheduledEditor] = React.useState<{ ruleId: string | null } | null>(
null
)
const [scheduledDraft, setScheduledDraft] = React.useState<ScheduledReportDraft | null>(null)
/** 「正在编辑」显示的名字(与保存目标同源)。 */
const editorTargetName = React.useMemo((): string => {
if (!editor) return ''
if (editor.ruleType === 'leave_notification') return leaveNotificationRule?.name ?? '退群通知'
if (editor.ruleType === 'scheduled_report') {
return scheduledDraft?.name?.trim() || '新建定时日报'
}
return dailyReportTarget?.name ?? '新建自动化'
}, [editor, leaveNotificationRule, dailyReportTarget, scheduledDraft])
/**
* 首页汇总卡:数量 / 运行中 / 最近一次「下次执行」。
* 数据直接来自真实规则 —— 加载中不显示(避免闪一个假的"0 条")。
*/
const scheduledSummary = React.useMemo(() => {
if (loading) return null
const now = new Date()
const running = scheduledRules.filter((rule) => rule.enabled)
const nextRuns = running
.map((rule) => normalizeScheduledReportConfig(rule.scheduledReport))
.filter((config) => !config.targetNeedsReview)
.map((config) => calculateNextRunAt(config.schedule.time, now))
.sort()
return {
total: scheduledRules.length,
running: running.length,
nextRunLabel: nextRuns.length ? formatNextRunAt(nextRuns[0]) : '—'
}
}, [scheduledRules, loading])
/** 群标识 → 显示名(列表与编辑器共用,保证任何地方都不出现裸 id)。 */
const resolveScheduledGroupDisplay = React.useCallback(
(raw: string): string => resolveGroupDisplayName(raw, groups),
[groups]
)
const openScheduledEditor = (rule: AutomationRule | null): void => {
setScheduledDraft(rule ? createDraftFromRule(rule) : createEmptyScheduledReportDraft())
setScheduledEditor({ ruleId: rule?.id ?? null })
}
const closeScheduledEditor = (): void => {
setScheduledEditor(null)
setScheduledDraft(null)
}
/** 保存:**真的**创建 / 更新一条 `scheduled_report` 规则。 */
const handleScheduledSave = async (draft: ScheduledReportDraft): Promise<void> => {
if (!scheduledEditor) return
const target = scheduledEditor.ruleId
? (scheduledRules.find((rule) => rule.id === scheduledEditor.ruleId) ?? null)
: null
// 编辑态但目标不存在 ⇒ 报错,绝不静默新建(与 @我日报同一条红线)。
if (scheduledEditor.ruleId && !target) {
toast({
description: '要编辑的定时日报已不存在,请返回列表重新打开',
variant: 'destructive',
duration: 3600
})
return
}
setSaving(true)
const payload = draftToAutomationRuleDraft(draft)
const saved =
scheduledEditor.ruleId && target
? await automationApi.updateRule(target.id, payload)
: await automationApi.createRule(payload)
setSaving(false)
if (!saved) {
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
return
}
setRules((current) =>
current.some((item) => item.id === saved.id)
? current.map((item) => (item.id === saved.id ? saved : item))
: [...current, saved]
)
closeScheduledEditor()
toast({
description: scheduledEditor.ruleId ? `已保存「${saved.name}」` : `已创建「${saved.name}」`,
duration: 2800
})
void automationApi.getStatus().then(setStatus)
}
/** 立即执行:走与 scheduler 完全相同的执行链路(manual trigger)。 */
const handleScheduledRunNow = async (rule: AutomationRule): Promise<void> => {
setBusyRuleId(rule.id)
const result = await automationApi.runScheduledReportRule(rule.id)
setBusyRuleId(null)
toast({
description: result.success
? `「${rule.name}」已执行完成`
: result.error || '定时日报执行失败,请查看执行日志',
variant: result.success ? undefined : 'destructive',
duration: 3600
})
// 执行结果落在 Automation Execution Log,切到日志 tab 就能看到。
void reload()
}
/** 启停:真写 store。 */
const handleScheduledToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
setBusyRuleId(rule.id)
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
setBusyRuleId(null)
if (!updated) {
toast({ description: '切换失败,规则状态未改变', variant: 'destructive', duration: 3200 })
return
}
setRules((current) => current.map((item) => (item.id === updated.id ? updated : item)))
void automationApi.getStatus().then(setStatus)
}
/** 删除:走统一的确认弹层(与其它规则类型一致)。 */
const handleScheduledDelete = (rule: AutomationRule): void => {
setPendingDelete(rule)
}
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
setBusyRuleId(rule.id)
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
setBusyRuleId(null)
if (!updated) {
toast({ description: '切换失败,规则状态未改变', variant: 'destructive', duration: 3200 })
return
}
setRules((current) => current.map((item) => (item.id === updated.id ? updated : item)))
void automationApi.getStatus().then(setStatus)
}
const handleSave = async (draft: AutomationRuleDraft): Promise<void> => {
if (!editor) return
const target = editor.ruleType === 'daily_report' ? dailyReportTarget : editor.rule
/*
* 编辑态但目标不存在 ⇒ **报错,绝不许静默新建**。
*
* 这里以前写的是 `editor.mode === 'create' || !target ? createRule : updateRule` ——
* 把"没有目标"当成了"新建",于是用户以为在改 A、实际多出一条新规则。
* 新建只能由 `mode === 'create'` 决定;`edit` 且无目标是不可恢复的状态,必须说出来。
*/
if (editor.mode === 'edit' && !target) {
toast({
description: '要编辑的规则已不存在,请返回列表重新打开',
variant: 'destructive',
duration: 3600
})
return
}
setSaving(true)
let saved: AutomationRule | null = null
if (editor.mode === 'create') {
saved = await automationApi.createRule(draft)
} else if (target) {
saved = await automationApi.updateRule(target.id, draft)
}
setSaving(false)
if (!saved) {
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
return
}
setRules((current) => {
const exists = current.some((item) => item.id === saved.id)
return exists
? current.map((item) => (item.id === saved.id ? saved : item))
: [...current, saved]
})
setEditor(null)
toast({
description: editor.mode === 'create' ? `已创建「${saved.name}」` : `已保存「${saved.name}」`,
duration: 2800
})
void automationApi.getStatus().then(setStatus)
}
/**
* 保存「退群通知」规则。
*
* 走独立的 singleton upsert 通道:这条规则 id 固定,保存永远不会多出一条。
* 保存成功后清掉迁移遗留的 `targetNeedsReview`(用户已经做过选择)。
*/
const handleLeaveNotificationSave = async (
input: LeaveNotificationSaveInput
): Promise<void> => {
// 静默 return 会让"点保存没反应"变成用户眼里的坏掉,必须说出来。
if (!leaveNotificationRule) {
toast({
description: '退群通知规则已不存在,请返回列表重新打开',
variant: 'destructive',
duration: 3600
})
return
}
setSaving(true)
const saved = await automationApi.saveLeaveNotificationRule({
...leaveNotificationRule,
enabled: input.enabled,
leaveNotification: input.config
})
setSaving(false)
if (!saved) {
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
return
}
setRules((current) =>
current.some((item) => item.id === saved.id)
? current.map((item) => (item.id === saved.id ? saved : item))
: [...current, saved]
)
setEditor(null)
toast({ description: `已保存「${saved.name}」`, duration: 2800 })
void automationApi.getStatus().then(setStatus)
}
const confirmDelete = async (): Promise<void> => {
if (!pendingDelete) return
const target = pendingDelete
setPendingDelete(null)
setBusyRuleId(target.id)
const removed = await automationApi.deleteRule(target.id)
setBusyRuleId(null)
if (!removed) {
toast({ description: '删除失败,规则仍然存在', variant: 'destructive', duration: 3200 })
return
}
setRules((current) => current.filter((item) => item.id !== target.id))
toast({ description: `已删除「${target.name}」`, duration: 2800 })
}
const handleClearExecutions = async (): Promise<void> => {
setClearing(true)
const cleared = await automationApi.clearExecutions()
setClearing(false)
if (!cleared) {
toast({ description: '清空失败,请稍后重试', variant: 'destructive', duration: 3200 })
return
}
setExecutions([])
void automationApi.getStatus().then(setStatus)
}
const capability = status.sendCapability
const capabilityReady = capability.ready && capability.canSendText && capability.canSendImage
const showEditor = tab === 'rules' && editor !== null
return (
<div className="automation-page">
<header className="automation-page-header">
<div>
<h1>自动化</h1>
<p>当群里出现符合条件的消息时,TraceMemo 会自动执行你配置的动作。</p>
</div>
{tab === 'rules' && !showEditor ? (
<Button
onClick={() => {
lastDailyTargetRef.current = null
setEditor({ mode: 'create', rule: null, ruleType: 'daily_report' })
}}
>
新建自动化
</Button>
) : null}
</header>
{!apiAvailable ? (
<p className="automation-notice warning">
自动化接口尚未就绪(可能正在启动或版本不匹配)。下面的数据可能不是最新的。
</p>
) : null}
{!dbReady ? (
<p className="automation-notice warning">
微信数据库尚未连接,规则可以编辑,但无法读取群列表,也不会触发。
</p>
) : null}
<section className="automation-status-bar" aria-label="自动化状态">
<div className="automation-status-card">
<span className="automation-status-label">消息监听</span>
<span className={`automation-status-value ${status.listening ? 'ok' : 'off'}`}>
{status.listening ? '运行中' : '未在监听'}
</span>
</div>
<div className="automation-status-card">
<span className="automation-status-label">发送能力</span>
<span className={`automation-status-value ${capabilityReady ? 'ok' : 'warn'}`}>
{capabilityReady ? '可以发送文字和图片' : capability.ready ? '发送能力不完整' : '尚未就绪'}
</span>
<small className="automation-status-note">{capability.message}</small>
{!capabilityReady && onOpenSendSettings ? (
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
去设置发送能力
</Button>
) : null}
</div>
<div className="automation-status-card">
<span className="automation-status-label">今日执行</span>
<span className="automation-status-value">
{status.todaySuccesses} / {status.todayExecutions}
</span>
<small className="automation-status-note">成功 / 总计</small>
</div>
</section>
{!showEditor ? (
<SegmentedControl
value={tab}
onValueChange={(value) => setTab(value as AutomationTab)}
aria-label="自动化视图"
>
<SegmentedControlItem value="rules">规则</SegmentedControlItem>
<SegmentedControlItem value="logs">执行日志</SegmentedControlItem>
</SegmentedControl>
) : null}
{showEditor && editor ? (
<div className="automation-rule-type-shell">
{/*
切类型 = **换一条规则来编辑**,不是"把这条规则改成另一类"。
以前这里只改 `ruleType`,把上个类型的 `mode`/`rule` 一起带过来,
于是"从退群通知切到日报"会留下 `mode='edit' + rule=null` 的中间态,
保存时再隐式回落到内置日报 —— 用户以为在改退群通知,实际改掉了日报。
现在切换时**显式重算编辑目标**,并把当前目标名显示出来。
*/}
<AutomationRuleTypeTabs
value={editor.ruleType}
onValueChange={(next) => {
if (next === editor.ruleType) return
// 离开定时日报时清掉它的编辑态,下次进来回到列表。
closeScheduledEditor()
if (next === 'scheduled_report') {
// 定时日报是**多条**规则:先给列表,不直接进编辑器。
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
return
}
if (next === 'leave_notification') {
// 退群通知是 singleton:编辑目标就是它自己,不依赖规则列表。
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
return
}
// 日报可以有多条:目标必须是**具体某一条**或"新建",
// 绝不允许留下 mode='edit' 却没有目标的状态。
const target = resolveDailyTarget()
lastDailyTargetRef.current = target
setEditor(
target
? { mode: 'edit', rule: target, ruleType: 'daily_report' }
: { mode: 'create', rule: null, ruleType: 'daily_report' }
)
}}
/>
{/*
编辑目标必须**可见**。
保存的目标与这里显示的名字来自**同一个变量** —— 不允许 UI 显示一个、保存改另一个。
*/}
{editor.ruleType !== 'scheduled_report' || scheduledEditor ? (
<p className="automation-editing-target" data-testid="automation-editing-target">
正在编辑:<strong>{editorTargetName}</strong>
</p>
) : null}
{editor.ruleType === 'daily_report' ? (
/*
* 编辑态却没有目标(规则被删/列表刷新后消失)—— 不能让用户对着一个
* 标题写着「编辑自动化」的空表单填半天,最后保存时报错。
*/
editor.mode === 'edit' && !dailyReportTarget ? (
<div className="automation-notice warning automation-notice-stack">
<p>要编辑的规则已不存在(可能已被删除)。</p>
<Button variant="outline" size="sm" onClick={() => setEditor(null)}>
返回规则列表
</Button>
</div>
) : (
<RuleEditorPanel
mode={editor.mode}
rule={dailyReportTarget}
groups={groups}
saving={saving}
onCancel={() => setEditor(null)}
onSave={(draft) => void handleSave(draft)}
/>
)
) : editor.ruleType === 'scheduled_report' ? (
scheduledEditor && scheduledDraft ? (
<ScheduledReportEditor
// 换一条规则就重建编辑器,避免把上一条的草稿带过来。
key={scheduledEditor.ruleId ?? 'new'}
initialDraft={scheduledDraft}
groups={groups}
sendableContacts={sendableContacts}
saving={saving}
onBack={closeScheduledEditor}
onCancel={closeScheduledEditor}
onSave={(draft) => void handleScheduledSave(draft)}
{...(onOpenModelSettings ? { onOpenModelSettings } : {})}
{...(scheduledEditor.ruleId
? (() => {
const rule = scheduledRules.find(
(item) => item.id === scheduledEditor.ruleId
)
const legacy = rule?.scheduledReport?.legacyTarget
return legacy ? { legacyTarget: legacy } : {}
})()
: {})}
notificationSlot={
<ScheduledReportNotificationPanel
rule={scheduledRules.find((item) => item.id === scheduledEditor.ruleId) ?? null}
{...(onOpenAgentHub ? { onOpenAgentHub } : {})}
/>
}
/>
) : (
<ScheduledReportRuleList
rules={scheduledRules}
loading={loading}
busyRuleId={busyRuleId}
resolveGroupDisplay={resolveScheduledGroupDisplay}
// 与另外两个类型的「取消」等价:退出本类型,回到规则面板。
// 少了这一个,用户在定时日报列表上就只能靠切 tab 绕出去。
onBack={() => setEditor(null)}
onCreate={() => openScheduledEditor(null)}
onEdit={openScheduledEditor}
onRunNow={(rule) => void handleScheduledRunNow(rule)}
onToggle={(rule, enabled) => void handleScheduledToggle(rule, enabled)}
onDelete={handleScheduledDelete}
/>
)
) : leaveNotificationRule ? (
<LeaveNotificationEditor
rule={leaveNotificationRule}
contacts={sendableContacts}
monitoredCount={monitoredGroupCount}
monitoredGroups={monitoredGroups}
saving={saving}
{...(capabilityReady
? {}
: {
// 只提示,不阻断编辑。
sendCapabilityWarning:
'当前发送能力未就绪:退群事件仍会被记录,但通知发送会失败。'
})}
onOpenMonitoredGroups={onOpenExitMonitorGroups}
onCancel={() => setEditor(null)}
onSave={(input) => void handleLeaveNotificationSave(input)}
/>
) : loading ? (
/*
* **正在读,不是"没有"。**
*
* 从「退群监控 → 退群通知自动化」深链进来时,编辑器是同步打开的,
* 而规则列表还在 IPC 回来的路上 —— 这一帧必然还没有规则。
* 旧写法在这里直接渲染「尚未就绪 / 版本不匹配」,把最正常的一种
* 加载状态说成了故障,必须区分开。
*/
<div className="automation-loading">
<Spinner />
<span>正在读取退群通知规则…</span>
</div>
) : (
/*
* 读完了**确实没有**这条规则。
*
* `builtin-leave-notification` 由主进程 `AutomationRuleStore` 首次加载时自动播种,
* 所以走到这里通常意味着:跑着的主进程还是迁移前的旧构建
* (渲染层已经升级,`out/main` 没有)。如实说明并给一个重试入口。
*/
<div className="automation-notice warning automation-notice-stack">
<p>没有找到内置的退群通知规则。</p>
<small>
这条规则应由主进程在首次启动时自动创建。如果你刚更新过版本,
请重启 TraceMemo —— 已经跑起来的主进程不会自动换成新代码。
</small>
<Button
variant="outline"
size="sm"
disabled={loading}
onClick={() => {
setLoading(true)
void reload().finally(() => setLoading(false))
}}
>
重新加载
</Button>
</div>
)}
</div>
) : tab === 'rules' ? (
<RuleListPanel
rules={rules}
groups={groups}
loading={loading}
busyRuleId={busyRuleId}
leaveNotification={
leaveNotificationRule
? {
rule: leaveNotificationRule,
monitoredCount: monitoredGroupCount,
contactName: leaveContactName
}
: null
}
scheduledReport={scheduledSummary}
onManageScheduledReport={() =>
setEditor({ mode: 'edit', rule: null, ruleType: 'scheduled_report' })
}
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
onEdit={(rule) => {
// 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。
lastDailyTargetRef.current = rule
setEditor({ mode: 'edit', rule, ruleType: rule.ruleType ?? 'daily_report' })
}}
onDelete={(rule) => setPendingDelete(rule)}
onCreate={() => {
// 新建是"还没有对象",所以不记住任何规则。
lastDailyTargetRef.current = null
setEditor({ mode: 'create', rule: null, ruleType: 'daily_report' })
}}
onEditLeaveNotification={() =>
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
}
/>
) : (
<ExecutionLogPanel
executions={executions}
loading={loading}
clearing={clearing}
onSelect={setSelectedExecution}
onClear={() => void handleClearExecutions()}
/>
)}
<ExecutionDetailDrawer
execution={selectedExecution}
onClose={() => setSelectedExecution(null)}
/>
{pendingDelete ? (
<div className="automation-confirm-layer" role="presentation" onClick={() => setPendingDelete(null)}>
<div
className="automation-confirm"
role="alertdialog"
aria-modal="true"
aria-label="删除自动化"
onClick={(event) => event.stopPropagation()}
>
<h2>删除「{pendingDelete.name}」?</h2>
<p>删除后不会再触发,已有的执行记录会保留。</p>
<div className="automation-confirm-actions">
<Button variant="ghost" onClick={() => setPendingDelete(null)}>
取消
</Button>
<Button variant="destructive" onClick={() => void confirmDelete()}>
删除
</Button>
</div>
</div>
</div>
) : null}
</div>
)
}
@@ -1,139 +0,0 @@
import * as React from 'react'
import type { AutomationRuleDraft } from '../../../../shared/automation'
/**
* EffectPreview —— 微信式效果模拟预览。
*
* **纯前端模拟**:不调用真实微信发送、不调日报生成、不调 AI。
* 它的全部作用是在用户按下保存之前,把「这条规则到底会做什么」演一遍。
*
* 与表单**逐项联动**(这是验收点):
* - 改关键词 → 左侧模拟消息里的内容跟着变;
* - 改回复文案 → 右侧气泡跟着变;
* - 关闭「回复确认」→ 右侧气泡消失;
* - 关闭「必须真正 @我」→ 左侧不再显示 `@你`;
* - 关闭「生成日报」→ 不再出图片,并说明原因;
* - 关闭「发送图片」→ 日报仍会生成但不会进群,说明区分开。
*/
const SAMPLE_SENDER_NAME = '张三'
function isActionEnabled(draft: AutomationRuleDraft, type: string): boolean {
return draft.actions.some((action) => action.type === type && action.enabled)
}
function replyText(draft: AutomationRuleDraft): string {
const action = draft.actions.find((item) => item.type === 'replyText' && item.enabled)
return action?.text?.trim() || '(未填写回复内容)'
}
/** 左侧模拟消息的正文:`@你 今日<关键词>`。关键词为空时给一句通用占位,而不是伪造关键词。 */
function sampleIncomingText(draft: AutomationRuleDraft): string {
const keyword = draft.conditions.keyword.trim()
const body = keyword ? `今日${keyword}` : '今天群里有什么新消息'
return draft.conditions.requireMentionMe ? `@你 ${body}` : body
}
export function EffectPreview({ draft }: { draft: AutomationRuleDraft }): React.ReactElement {
const mentionMe = draft.conditions.requireMentionMe
const hasReply = isActionEnabled(draft, 'replyText')
const hasReport = isActionEnabled(draft, 'generateReport')
const hasSend = isActionEnabled(draft, 'sendReportImage')
const showReportImage = hasReport && hasSend
return (
<section className="automation-preview" aria-label="效果模拟预览">
<div className="automation-preview-heading">
<h3>效果预览</h3>
<span className="automation-preview-badge">仅预览,不会真实发送</span>
</div>
<div className="automation-preview-phone">
<div className="automation-preview-chat">
{/* 左侧:触发消息 */}
<div className="automation-chat-row incoming">
<span className="automation-chat-avatar" aria-hidden="true">
{SAMPLE_SENDER_NAME.slice(0, 1)}
</span>
<div className="automation-chat-body">
<span className="automation-chat-name">{SAMPLE_SENDER_NAME}</span>
<span className="automation-chat-bubble">{sampleIncomingText(draft)}</span>
</div>
</div>
{mentionMe ? (
<p className="automation-preview-hint">
只有当对方<strong>真正</strong> @ 你时才会触发;正文里手打的「@昵称」不算。
</p>
) : (
<p className="automation-preview-hint">
当前不要求 @你,该会话内任何含关键词的消息都会触发。
</p>
)}
{/* 右侧:自动回复 */}
{hasReply ? (
<div className="automation-chat-row outgoing">
<div className="automation-chat-body">
<span className="automation-chat-name">我</span>
<span className="automation-chat-bubble">{replyText(draft)}</span>
</div>
<span className="automation-chat-avatar self" aria-hidden="true">
我
</span>
</div>
) : (
<p className="automation-preview-hint muted">未启用「回复确认」,命中后不会先回一句。</p>
)}
{/* 右侧:日报图片 */}
{showReportImage ? (
<div className="automation-chat-row outgoing">
<div className="automation-chat-body">
<span className="automation-chat-name">我</span>
<figure className="automation-chat-image">
<div className="automation-chat-image-canvas" aria-hidden="true">
<span className="automation-chat-image-title">群聊日报</span>
<span className="automation-chat-image-line" />
<span className="automation-chat-image-line short" />
<span className="automation-chat-image-line" />
<span className="automation-chat-image-line short" />
</div>
<figcaption>日报图片预览</figcaption>
</figure>
</div>
<span className="automation-chat-avatar self" aria-hidden="true">
我
</span>
</div>
) : (
<p className="automation-preview-hint muted">
{!hasReport
? '未启用「生成日报」,命中后不会产出日报。'
: '日报仍会生成,但未启用发送,不会发到群里。'}
</p>
)}
</div>
</div>
<dl className="automation-preview-facts">
<div>
<dt>生效范围</dt>
<dd>
{draft.conditions.conversationIds.length
? `已选 ${draft.conditions.conversationIds.length} 个群`
: '所有群聊'}
</dd>
</div>
<div>
<dt>触发间隔</dt>
<dd>{draft.cooldownSeconds > 0 ? `${draft.cooldownSeconds} 秒` : '不限制'}</dd>
</div>
<div>
<dt>自己发的消息</dt>
<dd>{draft.conditions.ignoreSelf ? '不触发' : '也会触发'}</dd>
</div>
</dl>
</section>
)
}
@@ -1,144 +0,0 @@
import * as React from 'react'
import { AUTOMATION_EXECUTION_STATUS_LABELS } from '../../../../shared/automation'
import type { AutomationExecution } from '../../../../shared/automation'
import { Button } from '../../components/ui'
import {
STEP_STATUS_LABELS,
describeSkippedStep,
formatClockTime,
formatDuration,
formatTriggerTime,
stepStatusTone
} from './model/format'
/**
* ExecutionDetailDrawer —— 执行详情抽屉(对应 Stitch 设计稿 3 的右半部分)。
*
* 用户来这里只想知道一件事:**到底坏在哪一步**。
* 所以步骤列表是主体,每个失败步骤都要把原因写在脸上;
* 「已跳过」的步骤还要说明是「上一步挂了」还是「规则本来就没配」。
*
* 抽屉而不是居中弹窗:详情是「从列表里钻进去看」,右侧抽屉保留了列表的位置感。
*/
export interface ExecutionDetailDrawerProps {
execution: AutomationExecution | null
onClose: () => void
}
export function ExecutionDetailDrawer({
execution,
onClose
}: ExecutionDetailDrawerProps): React.ReactElement | null {
const open = execution !== null
React.useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!execution) return null
return (
<div className="automation-drawer-layer" role="presentation" onClick={onClose}>
<aside
className="automation-drawer"
role="dialog"
aria-modal="true"
aria-label="执行详情"
onClick={(event) => event.stopPropagation()}
>
<header className="automation-drawer-header">
<div>
<span className="automation-drawer-eyebrow">执行详情</span>
<h2>{execution.ruleName}</h2>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
关闭
</Button>
</header>
<div className="automation-drawer-body">
<dl className="automation-detail-facts">
<div>
<dt>触发时间</dt>
<dd>{formatTriggerTime(execution.triggerTime)}</dd>
</div>
<div>
<dt>来源</dt>
<dd>{execution.sourceDisplayName}</dd>
</div>
<div>
<dt>结果</dt>
<dd>
<span className={`automation-status-chip ${execution.status}`}>
{AUTOMATION_EXECUTION_STATUS_LABELS[execution.status]}
</span>
</dd>
</div>
<div>
<dt>总耗时</dt>
<dd>{formatDuration(execution.durationMs)}</dd>
</div>
</dl>
{execution.errorSummary ? (
<p className="automation-detail-error">
<strong>失败原因:</strong>
{execution.errorSummary}
</p>
) : null}
<section className="automation-detail-steps">
<h3>执行步骤</h3>
{execution.steps.length === 0 ? (
<p className="automation-detail-empty">
本次执行没有产生步骤记录(可能在发送能力检查阶段就被拦下了)。
</p>
) : (
<ol className="automation-step-list">
{execution.steps.map((step) => (
<li key={step.key} className={`automation-step ${stepStatusTone(step.status)}`}>
<span className="automation-step-marker" aria-hidden="true" />
<div className="automation-step-body">
<div className="automation-step-title">
<span className="automation-step-label">{step.label}</span>
<span className="automation-step-status">
{STEP_STATUS_LABELS[step.status]}
</span>
</div>
<div className="automation-step-meta">
{step.status === 'skipped' ? (
<span>{describeSkippedStep(step, execution.steps)}</span>
) : (
<>
<span>{formatClockTime(step.startedAt)}</span>
<span className="automation-step-sep" aria-hidden="true">
→
</span>
<span>{formatClockTime(step.finishedAt)}</span>
<span className="automation-step-duration">
{formatDuration(step.durationMs)}
</span>
</>
)}
</div>
{step.detail ? (
<p className="automation-step-detail">{step.detail}</p>
) : null}
{step.error ? <p className="automation-step-error">{step.error}</p> : null}
</div>
</li>
))}
</ol>
)}
</section>
</div>
</aside>
</div>
)
}
@@ -1,124 +0,0 @@
import * as React from 'react'
import { AUTOMATION_EXECUTION_STATUS_LABELS } from '../../../../shared/automation'
import type { AutomationExecution } from '../../../../shared/automation'
import { Button, Spinner } from '../../components/ui'
import { STEP_STATUS_LABELS, formatDuration, formatTriggerTime, stepStatusTone } from './model/format'
/**
* ExecutionLogPanel —— 执行日志列表(对应 Stitch 设计稿 3 的左半部分)。
*
* 每行是一条**用户层**记录:时间 / 规则名 / 来源 / 结果 / 耗时 / 步骤轨迹。
*
* 刻意不展示 wxid、localId、serverId、source XML、原始 payload 与图片路径 ——
* `AutomationExecution` 里本来就不带这些字段,这里也不允许从别处补。
*/
export interface ExecutionLogPanelProps {
executions: AutomationExecution[]
loading: boolean
clearing: boolean
onSelect: (execution: AutomationExecution) => void
onClear: () => void
}
/** 把 5 个步骤压成一串小圆点,一眼能看出「卡在哪一步」。 */
function StepTrail({ execution }: { execution: AutomationExecution }): React.ReactElement {
if (!execution.steps.length) {
return <span className="automation-trail-empty">—</span>
}
return (
<span className="automation-trail" aria-label="执行步骤">
{execution.steps.map((step, index) => (
<React.Fragment key={step.key}>
{index > 0 ? <span className="automation-trail-link" aria-hidden="true" /> : null}
<span
className={`automation-trail-dot ${stepStatusTone(step.status)}`}
title={`${step.label}:${STEP_STATUS_LABELS[step.status]}`}
aria-label={`${step.label}:${STEP_STATUS_LABELS[step.status]}`}
role="img"
/>
</React.Fragment>
))}
</span>
)
}
export function ExecutionLogPanel({
executions,
loading,
clearing,
onSelect,
onClear
}: ExecutionLogPanelProps): React.ReactElement {
return (
<div className="automation-log-panel">
<div className="automation-section-heading">
<h2>执行日志</h2>
<div className="automation-log-heading-actions">
<span className="automation-section-note">最近 {executions.length} 条</span>
<Button
variant="outline"
size="sm"
onClick={onClear}
disabled={clearing || executions.length === 0}
>
{clearing ? '清空中…' : '清空记录'}
</Button>
</div>
</div>
{loading ? (
<div className="automation-loading">
<Spinner />
<span>正在读取执行记录…</span>
</div>
) : executions.length === 0 ? (
<div className="automation-empty-card">
<p>暂无执行记录。</p>
<small>规则命中并开始执行后,这里会留下完整的步骤轨迹。</small>
</div>
) : (
<div className="automation-log-table" role="table" aria-label="执行日志">
<div className="automation-log-row head" role="row">
<span role="columnheader">时间</span>
<span role="columnheader">规则</span>
<span role="columnheader">来源</span>
<span role="columnheader">结果</span>
<span role="columnheader">耗时</span>
<span role="columnheader">步骤轨迹</span>
<span role="columnheader" />
</div>
{executions.map((execution) => (
<div key={execution.executionId} className="automation-log-row" role="row">
<span role="cell" className="automation-log-time">
{formatTriggerTime(execution.triggerTime)}
</span>
<span role="cell" className="automation-log-rule">
{execution.ruleName}
</span>
<span role="cell" className="automation-log-source">
{execution.sourceDisplayName}
</span>
<span role="cell">
<span className={`automation-status-chip ${execution.status}`}>
{AUTOMATION_EXECUTION_STATUS_LABELS[execution.status]}
</span>
</span>
<span role="cell" className="automation-log-duration">
{formatDuration(execution.durationMs)}
</span>
<span role="cell">
<StepTrail execution={execution} />
</span>
<span role="cell" className="automation-log-action">
<Button variant="link" size="sm" onClick={() => onSelect(execution)}>
查看详情
</Button>
</span>
</div>
))}
</div>
)}
</div>
)
}
@@ -1,436 +0,0 @@
import * as React from 'react'
import { Button, Input, RadioGroup, RadioGroupItem, Switch } from '../../components/ui'
import {
LEAVE_NOTIFICATION_TARGET_OPTIONS,
describeLeaveNotificationNotifyScope,
normalizeLeaveNotificationConfig,
type AutomationRule,
type LeaveNotificationConfig,
type LeaveNotificationNotifyScope,
type LeaveNotificationTargetType
} from '../../../../shared/automation'
import { insertGroupNamePlaceholder } from '../../../../shared/group-exit-monitor'
import { LeaveNotificationPreview } from './LeaveNotificationPreview'
import type { AutomationGroupOption } from './model/api'
import {
LeaveNotificationTargetPicker,
type LeaveNotificationContactOption
} from './LeaveNotificationTargetPicker'
import { LeaveNotificationTemplateEditor } from './LeaveNotificationTemplateEditor'
/**
* LeaveNotificationEditor —— 「自动化 → 规则 → 退群通知」。
*
* 这是**真实配置页**:保存会落盘成一条 singleton 规则(`builtin-leave-notification`),
* 由 `AutomationService.handleGroupExit` 在退群事件到来时执行。
*
* 职责边界在 UI 上一眼可见,是**两层**而不是一层:
* 退群监控 = 监测哪些群有人退出(范围由它管);
* 本规则 = ① 这批群里**哪些要通知**(二次勾选);② 通知**发到哪**。
*
* ① 就是旧版「退群监控 → 通知群聊」的那份逐群勾选。它曾在自动化迁移里被压成单值目标而丢失,
* 导致"规则一启用就全量通知";`notifyScope` 把它恢复回来,所以这里**有**勾选控件。
*/
export interface LeaveNotificationSaveInput {
enabled: boolean
config: LeaveNotificationConfig
}
export interface LeaveNotificationEditorProps {
/** 已持久化的退群通知规则(singleton,永远存在)。 */
rule: AutomationRule
/** 「指定好友」的可选项(main 侧已过滤,只含可发送的个人联系人)。 */
contacts: LeaveNotificationContactOption[]
/** 真实已监控群聊数量(来自退群监控,自动化不维护副本)。 */
monitoredCount: number
/** 真实已监控群聊清单 —— 二次勾选的候选项,与 `monitoredCount` 同一次取数。 */
monitoredGroups: AutomationGroupOption[]
saving: boolean
/** 发送能力不完整时的一句话提示;为 undefined 表示能力正常。 */
sendCapabilityWarning?: string
/** 「管理监控群聊 →」—— 跳退群监控的管理群聊页,纯导航。 */
onOpenMonitoredGroups?: () => void
onCancel: () => void
onSave: (input: LeaveNotificationSaveInput) => void
}
/**
* 草稿初值。
*
* 一律过一遍 `normalizeLeaveNotificationConfig`:它本来就负责"补齐缺失字段 +
* 清掉互相矛盾的配置",所以契约新增字段(例如 `notifyScope`)时这里不会漏。
* 手拼字面量就会漏。
*/
function initialConfig(rule: AutomationRule): LeaveNotificationConfig {
return normalizeLeaveNotificationConfig(rule.leaveNotification)
}
export function LeaveNotificationEditor({
rule,
contacts,
monitoredCount,
monitoredGroups,
saving,
sendCapabilityWarning,
onOpenMonitoredGroups,
onCancel,
onSave
}: LeaveNotificationEditorProps): React.ReactElement {
const [enabled, setEnabled] = React.useState(rule.enabled)
const [config, setConfig] = React.useState<LeaveNotificationConfig>(() => initialConfig(rule))
const [groupFilter, setGroupFilter] = React.useState('')
// 切换编辑对象 / 保存后回填时重建草稿,避免把上一份改动带过来。
React.useEffect(() => {
setEnabled(rule.enabled)
setConfig(initialConfig(rule))
setGroupFilter('')
}, [rule])
const targetType = config.target.type
const selectedContactId = String(config.target.contactId || '')
const selectedContactName = React.useMemo(
() => contacts.find((contact) => contact.id === selectedContactId)?.name ?? '',
[contacts, selectedContactId]
)
/** 选了「指定好友」但那个联系人已经不在了(被删 / 不可发送 / 找不到)。 */
const contactMissing =
targetType === 'contact' && Boolean(selectedContactId) && !selectedContactName
/**
* 通知不是发给「当前群聊」、但内容里又没有群名 → 收件人认不出是哪个群。
*
* 只在非「当前群聊」时要求:发给群内时,群名是冗余的。
*/
const missingGroupName =
targetType !== 'source_chat' && !config.template.includes('{groupName}')
/** 通知范围(二次勾选)。`config.notifyScope` 是唯一权威,这里只是取短名。 */
const notifyScope: LeaveNotificationNotifyScope =
config.notifyScope === 'selected' ? 'selected' : 'all'
const selectedNotifyIds = React.useMemo(
() => new Set(config.notifyRoomIds),
[config.notifyRoomIds]
)
const visibleMonitoredGroups = React.useMemo(() => {
const keyword = groupFilter.trim().toLowerCase()
if (!keyword) return monitoredGroups
return monitoredGroups.filter((group) => group.name.toLowerCase().includes(keyword))
}, [monitoredGroups, groupFilter])
const selectTarget = (next: LeaveNotificationTargetType): void => {
setConfig((current) => ({
...current,
// 换目标类型时清掉上一个类型才有的字段,避免存下互相矛盾的配置。
// 同时**清除迁移遗留的「待重选」标记** —— 用户已经做了选择。
target: next === 'contact' ? { type: next, contactId: current.target.contactId } : { type: next }
}))
}
const selectNotifyScope = (next: LeaveNotificationNotifyScope): void => {
setConfig((current) => ({ ...current, notifyScope: next }))
}
const toggleNotifyRoom = (roomId: string): void => {
setConfig((current) => {
const chosen = new Set(current.notifyRoomIds)
if (chosen.has(roomId)) chosen.delete(roomId)
else chosen.add(roomId)
// 按候选项顺序落盘,勾选顺序不影响存下来的结果。
const ordered = monitoredGroups
.map((group) => group.id)
.filter((id) => chosen.has(id))
// 候选项还没加载出来时保留原样,避免把用户的勾选静默清空。
return {
...current,
notifyRoomIds: monitoredGroups.length ? ordered : current.notifyRoomIds
}
})
}
const handleSave = (): void => {
onSave({
enabled,
config: {
...config,
// 勾选集收敛到「当前已监控」这一集合内:否则监控范围缩小之后,
// 会留下一批界面上看不见、且永远不可能命中的幽灵勾选。
notifyRoomIds: monitoredGroups.length
? monitoredGroups.map((group) => group.id).filter((id) => selectedNotifyIds.has(id))
: config.notifyRoomIds,
targetNeedsReview: undefined
}
})
}
return (
<div className="automation-editor">
<header className="automation-editor-header">
<div>
<h2>退群通知</h2>
<p>当已监控群聊检测到成员退出时,自动发送通知。</p>
<div className="automation-leave-status-row">
<span className="automation-field-label">启用这条自动化</span>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-label="启用这条自动化"
/>
</div>
</div>
<div className="automation-editor-actions">
<Button variant="ghost" onClick={onCancel} disabled={saving}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</div>
</header>
{config.targetNeedsReview ? (
<p className="automation-notice warning">
旧版「通知群聊」是逐群配置的,无法无损转换成新的单一通知目标。已保留你的通知模板,
但<strong>在你重新选择通知目标之前不会发送任何通知</strong>。
</p>
) : null}
{contactMissing ? (
<p className="automation-notice warning">
之前选择的联系人已不存在或当前无法发送,请重新选择通知目标。
</p>
) : null}
{sendCapabilityWarning ? (
<p className="automation-notice warning">{sendCapabilityWarning}</p>
) : null}
<div className="automation-editor-body">
<div className="automation-editor-form">
<section className="automation-section">
<div className="automation-section-heading">
<h3>1 · 什么时候触发</h3>
</div>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">触发事件</span>
<span className="automation-static-value">检测到群成员退出</span>
<small>这是「退群监控」提供的事件,由本规则响应。</small>
</div>
</div>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">监控来源</span>
<span className="automation-static-value">退群监控</span>
</div>
</div>
{/*
第一层:只读的监控范围摘要 + 唯一入口。
「哪些群被监控」完全由退群监控决定,本规则不重复维护一份。
*/}
<div className="automation-leave-groups-card">
<div>
<span className="automation-field-label">已监控群聊</span>
<strong>{monitoredCount} 个群聊</strong>
<small>监控范围由「退群监控」管理</small>
</div>
<Button
variant="outline"
size="sm"
onClick={onOpenMonitoredGroups}
disabled={!onOpenMonitoredGroups}
>
管理监控群聊 →
</Button>
</div>
{/*
第二层:在上面这批已监控群聊里**二次勾选**哪些要通知。
这就是旧版「退群监控 → 通知群聊」的那份逐群勾选 ——
没有它,规则一启用就等于给全部已监控群聊发通知。
*/}
<div className="automation-inline-row">
<div className="automation-leave-scope">
<span className="automation-field-label">通知范围</span>
<RadioGroup
value={notifyScope}
onValueChange={(value) =>
selectNotifyScope(value as LeaveNotificationNotifyScope)
}
aria-label="通知范围"
className="automation-leave-scope-options"
>
<div className="automation-leave-radio option">
<RadioGroupItem value="all" id="leave-scope-all" />
<div className="automation-leave-radio-body">
<label htmlFor="leave-scope-all">全部已监控群聊</label>
<small>任一被监控的群有人退出都发通知。</small>
</div>
</div>
<div className="automation-leave-radio option">
<RadioGroupItem
value="selected"
id="leave-scope-selected"
disabled={!monitoredGroups.length}
/>
<div className="automation-leave-radio-body">
<label htmlFor="leave-scope-selected">仅选中的群聊</label>
<small>只对下面勾选的群发通知。</small>
</div>
</div>
</RadioGroup>
<small className="automation-leave-scope-summary">
当前覆盖:{describeLeaveNotificationNotifyScope(config, monitoredCount)}
</small>
</div>
</div>
{notifyScope === 'selected' ? (
monitoredGroups.length ? (
<div className="automation-field">
<div className="automation-section-heading">
<span className="automation-field-label">勾选要通知的群聊</span>
<span className="automation-section-note">
{selectedNotifyIds.size ? `已选 ${selectedNotifyIds.size} 个` : '尚未勾选'}
</span>
</div>
<Input
value={groupFilter}
onChange={(event) => setGroupFilter(event.target.value)}
placeholder="搜索群聊"
aria-label="搜索群聊"
/>
<div className="automation-group-list" role="group" aria-label="通知群聊">
{visibleMonitoredGroups.length === 0 ? (
<p className="automation-group-empty">没有匹配「{groupFilter}」的群聊</p>
) : (
visibleMonitoredGroups.map((group) => {
const checked = selectedNotifyIds.has(group.id)
return (
<label
key={group.id}
className={`automation-group-option ${checked ? 'selected' : ''}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleNotifyRoom(group.id)}
/>
<span>{group.name}</span>
</label>
)
})
)}
</div>
{selectedNotifyIds.size === 0 ? (
<p className="automation-section-hint">
<span aria-hidden="true">ⓘ</span>
一个群都没勾选,这条规则不会发送任何通知。
</p>
) : null}
</div>
) : (
<p className="automation-section-hint">
<span aria-hidden="true">ⓘ</span>
还没有设置监控范围。请先到「退群监控 → 管理群聊」选择要监控的群,
再回来勾选通知范围。
</p>
)
) : null}
</section>
<section className="automation-section">
<div className="automation-section-heading">
<h3>2 · 通知发送到哪里</h3>
</div>
<RadioGroup
value={targetType}
onValueChange={(value) => selectTarget(value as LeaveNotificationTargetType)}
aria-label="通知发送到哪里"
className="automation-leave-targets"
>
{LEAVE_NOTIFICATION_TARGET_OPTIONS.map((option) => (
<div key={option.type} className="automation-leave-radio option">
<RadioGroupItem value={option.type} id={`leave-target-${option.type}`} />
<div className="automation-leave-radio-body">
<label htmlFor={`leave-target-${option.type}`}>{option.label}</label>
<small>{option.description}</small>
</div>
</div>
))}
</RadioGroup>
{targetType === 'contact' ? (
<div className="automation-field">
<span className="automation-field-label">选择好友</span>
<LeaveNotificationTargetPicker
contacts={contacts}
selectedId={selectedContactId}
onSelect={(id) =>
setConfig((current) => ({
...current,
target: { type: 'contact', contactId: id },
targetNeedsReview: undefined
}))
}
ariaLabel="选择好友"
searchPlaceholder="搜索好友"
emptyText="没有可发送的联系人"
/>
</div>
) : null}
</section>
<section className="automation-section">
<div className="automation-section-heading">
<h3>3 · 通知内容</h3>
</div>
{/*
护栏:通知发给「别人」时,不带群名的通知等于「张三退群了」。
主进程已替用户补过一次(一次性迁移),这里兜的是"用户后来主动删掉了"的情况 ——
只提示 + 一键补上,不强行改用户文本。
*/}
{missingGroupName ? (
<div className="automation-notice warning automation-notice-stack">
<p>这份内容里没有群聊名,收件人看不出是哪个群退的人。</p>
<Button
variant="outline"
size="sm"
onClick={() =>
setConfig((current) => ({
...current,
template: insertGroupNamePlaceholder(current.template)
}))
}
>
补上群聊名
</Button>
</div>
) : null}
<LeaveNotificationTemplateEditor
template={config.template}
onChange={(next) =>
setConfig((current) => ({ ...current, template: next }))
}
/>
</section>
</div>
<LeaveNotificationPreview
config={config}
contactName={selectedContactName}
monitoredCount={monitoredCount}
/>
</div>
</div>
)
}
@@ -1,114 +0,0 @@
import * as React from 'react'
import {
describeLeaveNotificationNotifyScope,
describeMonitoredScope,
leaveNotificationTargetLabel,
type LeaveNotificationConfig
} from '../../../../shared/automation'
import {
createGroupExitPreviewSample,
renderLeaveNotificationText
} from '../../../../shared/group-exit-event'
/**
* LeaveNotificationPreview —— 右侧「效果预览」。
*
* 与左侧表单逐项联动:改模板 → 气泡变;改发送目标 → 顶部收件会话与底部「通知目标」一起变。
*
* **绝不发送**:即使规则已经真实接通,这里也只做渲染 ——
* 它拿的是 `GROUP_EXIT_PREVIEW_SAMPLE` 样本事件,不碰 `WechatActionGateway`。
* 模板渲染复用 shared 的唯一实现,所以预览与实发不会出现两套结果。
*/
export interface LeaveNotificationPreviewProps {
config: LeaveNotificationConfig
/** 「指定好友」已选联系人的显示名;未选 / 不是该目标时传空串。 */
contactName: string
/** 真实已监控群聊数量(来自退群监控)。 */
monitoredCount: number
}
export function LeaveNotificationPreview({
config,
contactName,
monitoredCount
}: LeaveNotificationPreviewProps): React.ReactElement {
// 样本事件只服务预览;时间取"此刻",不伪造历史日期。
const sample = React.useMemo(() => createGroupExitPreviewSample(Date.now()), [])
const chatTitle = React.useMemo((): string => {
switch (config.target.type) {
case 'source_chat':
return sample.groupName || '当前群聊'
case 'self':
return '我'
case 'file_transfer':
return leaveNotificationTargetLabel('file_transfer')
case 'contact':
return contactName.trim() || '未选择好友'
}
}, [config.target.type, contactName, sample.groupName])
const targetSummary = React.useMemo((): string => {
switch (config.target.type) {
case 'source_chat':
return '发生退群事件的群聊'
case 'self':
return '我'
case 'file_transfer':
return leaveNotificationTargetLabel('file_transfer')
case 'contact':
return contactName.trim() || '未选择好友'
}
}, [config.target.type, contactName])
const bubbleText = renderLeaveNotificationText(sample, config.template).trim() || '(模板为空)'
return (
<section className="automation-preview" aria-label="效果模拟预览">
<div className="automation-preview-heading">
<h3>效果预览</h3>
<span className="automation-preview-badge">仅预览,不会真实发送</span>
</div>
<div className="automation-preview-phone">
<div className="automation-preview-chat-title" data-testid="leave-preview-recipient">
<span>{chatTitle}</span>
</div>
<div className="automation-preview-chat">
{/* 退群监控产生的系统事件,不来自任何消息文本 */}
<p className="automation-preview-system">检测到成员退出</p>
<div className="automation-chat-row outgoing">
<div className="automation-chat-body">
<span className="automation-chat-name">我</span>
<span className="automation-chat-bubble pre">{bubbleText}</span>
</div>
<span className="automation-chat-avatar self" aria-hidden="true">
我
</span>
</div>
</div>
</div>
<dl className="automation-preview-facts">
<div>
<dt>触发事件</dt>
<dd>成员退出</dd>
</div>
<div>
<dt>监控来源</dt>
<dd>{describeMonitoredScope(monitoredCount)}</dd>
</div>
<div>
<dt>通知范围</dt>
<dd>{describeLeaveNotificationNotifyScope(config, monitoredCount)}</dd>
</div>
<div>
<dt>通知目标</dt>
<dd>{targetSummary}</dd>
</div>
</dl>
</section>
)
}

Some files were not shown because too many files have changed in this diff Show More