mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713232bd62 | ||
|
|
e83e85fe64 | ||
|
|
e5a1656644 | ||
|
|
194dd0035b | ||
|
|
c3629255ea | ||
|
|
345a0db49b | ||
|
|
5276bce060 | ||
|
|
0abbda1160 | ||
|
|
97c74859c0 | ||
|
|
4d5c6459fe | ||
|
|
c835d5c570 | ||
|
|
59872daf52 | ||
|
|
3bd3935042 | ||
|
|
77a6912ade | ||
|
|
4b83aaed61 | ||
|
|
7268188e40 | ||
|
|
069d836dea | ||
|
|
95384a4a92 | ||
|
|
9b82ca663b | ||
|
|
f5633a29cb | ||
|
|
766cd94788 | ||
|
|
b6d287a299 | ||
|
|
4c25236070 |
+2
-1
@@ -10,7 +10,8 @@ out
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat-personal/
|
||||
resources/runtime/darwin-arm64
|
||||
.native-runtime-source
|
||||
.omc
|
||||
.codex/
|
||||
skills-lock.json
|
||||
|
||||
@@ -25,16 +25,14 @@
|
||||
</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>
|
||||
|
||||
---
|
||||
|
||||
## 🎨 社区日报模板
|
||||
@@ -49,7 +47,7 @@ TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多
|
||||
|
||||
在 TraceMemo 中打开:
|
||||
|
||||
**日报 → 今日日报 → 日报模板 → 模板市场**
|
||||
**日报 → 社区模板市场**
|
||||
|
||||
即可查看、预览、安装和切换已发布的社区模板。
|
||||
|
||||
@@ -67,18 +65,21 @@ TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
|
||||
- 💬 **聊天档案与搜索**:浏览会话,按关键词、备注、昵称或 wxid 查找消息。
|
||||
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
|
||||
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
|
||||
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
|
||||
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
|
||||
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
|
||||
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
|
||||
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
|
||||
- 🧠 **本地知识库**:在本机建立索引,让跨会话、跨时间的查询更稳定。
|
||||
- 🖼️ **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图),识别结果可以在搜索和「问问微信」里被检索。识别全程不联网,原始图片不会因为本地识别而上传。
|
||||
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结,可保存为 HTML 与 PNG。
|
||||
- 🗣️ **群发言统计**:统计群成员的发言量和沉默成员,看清一个群里谁在说、谁一直没说。
|
||||
- 👀 **退群监控**:用成员快照对比记录群成员退出事件,支持多群与事件历史。
|
||||
- ⚙️ **自动化**:把上面几步按规则串起来——定时生成并发送日报、成员退群时发送通知;能发到哪里取决于当前的发送能力。
|
||||
- 🔊 **文字转语音**:把文字生成语音,试听后发送到当前会话。
|
||||
- 🤖 **Agent Hub**:在微信里向本机 TraceMemo 提问。
|
||||
- 🔌 **外部 Agent / Local HTTP API**:让 Codex 等外部 Agent 查询本机微信历史。
|
||||
|
||||
## 💻 平台支持
|
||||
|
||||
TraceMemo 2.4.0 支持:
|
||||
TraceMemo 2.5.0 支持:
|
||||
|
||||
- **Windows x64**
|
||||
- **macOS Apple Silicon(M 系列 / arm64)**
|
||||
@@ -86,6 +87,12 @@ TraceMemo 2.4.0 支持:
|
||||
|
||||
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
|
||||
|
||||
### 关于“发送能力”
|
||||
|
||||
浏览、搜索、日报生成、导出、知识库和图片文字索引都不需要额外的发送组件。只有**把内容真正发回微信**这一步——自动发送日报、退群通知、把语音发到会话——依赖本机发送能力:
|
||||
|
||||
发送能力未就绪、未绑定或发送失败时,报告本身仍会正常生成并保存在本机,执行记录会显示为“已生成,但未发送”或“已生成,发送失败”,可以稍后重试。
|
||||
|
||||
## 项目缘起
|
||||
|
||||
<details>
|
||||
@@ -138,11 +145,14 @@ TraceMemo 最早叫 **WechatExplorer**。
|
||||
| 想做什么 | 使用入口 |
|
||||
| ---------------------------------- | ----------------------------- |
|
||||
| 找记得原文或关键词的消息 | 档案搜索 |
|
||||
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
|
||||
| 找记得大意、但不知道在哪聊过的内容 | 问问微信(AI Search) |
|
||||
| 找到截图、公告图里写过的文字 | 问问微信 → 图片文字索引 |
|
||||
| 长期跨群查询历史 | 本地知识库 |
|
||||
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
|
||||
| 了解一个群今天或近 7 天聊了什么 | 日报 |
|
||||
| 看群里谁最活跃、谁一直没说话 | 档案 → 群聊 → 群发言统计 |
|
||||
| 持续关注群成员退出 | 退群监控 |
|
||||
| 按计划生成并发送群聊日报 | 定时日报 |
|
||||
| 按计划自动生成并发送群聊日报 | 自动化 |
|
||||
| 成员退群时自动发一条通知 | 自动化 → 退群通知 |
|
||||
| 把文字生成微信语音 | 文字转语音 |
|
||||
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
|
||||
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
|
||||
@@ -159,9 +169,9 @@ TraceMemo 最早叫 **WechatExplorer**。
|
||||
|
||||
## 文档
|
||||
|
||||
- [用户指南](./docs/README.md#用户指南)
|
||||
- [用户指南](./docs/README.md#档案与搜索)
|
||||
- [AI / Knowledge](./docs/README.md#ai-与知识库)
|
||||
- [Monitor / Automation](./docs/README.md#日报与自动化)
|
||||
- [日报与自动化](./docs/README.md#日报与自动化)
|
||||
- [Agent / API](./docs/README.md#agent--api)
|
||||
- [开发文档](./docs/development/overview.md)
|
||||
- [隐私与安全](./docs/user-guide/privacy.md)
|
||||
@@ -213,7 +223,7 @@ TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.co
|
||||
|
||||
这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
|
||||
|
||||
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。
|
||||
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能
|
||||
|
||||
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
|
||||
|
||||
|
||||
+7
-8
@@ -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,7 +61,6 @@ 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)
|
||||
|
||||
## 版本说明
|
||||
|
||||
|
||||
+12
-11
@@ -21,14 +21,15 @@ Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导
|
||||
- “帮我看看最近跟某人聊了些什么。”
|
||||
- “生成产品交流群今天的群聊总结图片。”
|
||||
|
||||
当前已实现的实时任务包括:
|
||||
Hub 把入站文字分成两类处理。
|
||||
|
||||
- 查看最近会话(数量限制为 1–20);
|
||||
- 查询你和某位联系人的近期聊天;
|
||||
- 用已配置的 AI 总结你和某位联系人近 7 天的聊天;
|
||||
- 生成今天、昨天或近 7 天的群聊总结图片;
|
||||
- 总结指定群成员在群里的近期发言;
|
||||
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
|
||||
**确定性的快捷动作**(不经过模型,命中就执行):
|
||||
|
||||
- 查看最近会话:数量限制为 1–20;
|
||||
- 生成群聊总结图片:今天、昨天或近 7 天(需要同时提到“群”和“图片 / 长图 / 日报 / 报告”);
|
||||
- 分析某个群成员的近期发言:可以指定“今天 / 昨天 / 最近 N 天”。
|
||||
|
||||
**其余问题**交给本机的 Query Agent:它和桌面端“问问微信”使用的是同一个实现,可以按需读取联系人、会话和时间范围来回答,必要时调用你在“设置 → AI 模型”里配置的 AI。例如“帮我看看最近跟某人聊了些什么”“上个月讨论过的项目地址在哪里”。
|
||||
|
||||
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
|
||||
|
||||
@@ -56,12 +57,12 @@ Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支
|
||||
|
||||
## 安全与边界
|
||||
|
||||
- Hub 使用本机通信,不把数据库直接暴露到公网;
|
||||
- Hub 在主进程内运行,不开放本地监听端口;它不会把数据库暴露到公网;
|
||||
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
|
||||
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
|
||||
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider;
|
||||
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
|
||||
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
|
||||
- Hub 理解请求或生成总结时,会调用你在“设置 → AI 模型”配置的 Provider;
|
||||
- 当前实时入口只处理文字消息。连接器会归一化收到的消息条目,但 Agent Hub 只把文本条目当作意图处理,尚未为图片、语音、文件和视频提供同等能力;
|
||||
- 当前没有实现群发、广播、定时任务或通用自主操作微信(定时日报属于「自动化」,不是 Agent Hub);
|
||||
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
|
||||
|
||||
## 无法连接时
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
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`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
|
||||
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。历史变量名 `WECHATEXPLORER_API_TOKEN` 仍被兼容读取,优先级为新变量高于旧变量;当前没有设定旧变量名的移除时间,新配置不要再使用它。
|
||||
|
||||
- `/api/v1/health` 是公开健康检查;
|
||||
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
||||
@@ -14,7 +16,8 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑
|
||||
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
|
||||
- 文件权限设置为 `0600`;
|
||||
- 在“API Center”中可以显示、复制和重新生成;
|
||||
- 重新生成后旧 Token 立即失效。
|
||||
- 重新生成后旧 Token 立即失效;
|
||||
- 服务端只认这个 Token,**不接受用环境变量覆盖**——Agent 一侧的环境变量只是把 Token 交给 Agent 自己的方式,不是鉴权来源。
|
||||
|
||||
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
||||
|
||||
|
||||
+169
-28
@@ -8,7 +8,9 @@
|
||||
- API 前缀:`/api/v1`
|
||||
- 默认只监听 loopback;不要把它当作公网服务。
|
||||
- `/api/v1/health` 无需 Token;其他端点需要 `Authorization: Bearer <TOKEN>`。
|
||||
- 请求体使用 JSON;响应为 JSON。
|
||||
- 请求体使用 JSON,单个请求体最大 `1 MiB`;超限返回 `413`。
|
||||
- 错误响应包含 `requestId`,响应头包含 `X-Request-Id`。客户端可传入 1-128 位的 `[A-Za-z0-9._:-]` 标识,否则服务端会生成 UUID。
|
||||
- 不支持的 HTTP method 返回 `405` 和 `Allow` 响应头。
|
||||
|
||||
## 最小请求
|
||||
|
||||
@@ -24,31 +26,168 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
|
||||
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
|
||||
|
||||
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
|
||||
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。应用生成的安装指令仍会提示:尚未升级的旧配置可以继续读取 `WECHATEXPLORER_API_TOKEN`,但新配置必须使用新变量名;如果两个变量都存在,以新变量为准。当前没有设定旧变量名的移除时间。
|
||||
|
||||
Token 由应用生成并保存在本机,**不接受用环境变量覆盖**:Agent 侧的环境变量只是把 Token 传给 Agent 自己的方式,不是服务端的鉴权来源。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 | 路径 | 作用 | 参数/请求体 |
|
||||
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
|
||||
| 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/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 删除。
|
||||
|
||||
### 这些端点与实时机器人有什么关系
|
||||
|
||||
- `/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 和微信连接器接收、处理和回复。
|
||||
|
||||
## 时间查询
|
||||
@@ -83,10 +222,12 @@ 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`:服务端处理或报告渲染失败。
|
||||
@@ -154,12 +295,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"`)。
|
||||
|
||||
@@ -186,11 +327,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` 表示追赶仍在后台进行,稍后重试即可拿到更新的覆盖。
|
||||
|
||||
@@ -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` 可在 v2.2.0 兼容期内继续使用旧变量。
|
||||
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 仍可继续使用旧变量 `WECHATEXPLORER_API_TOKEN`(当前没有设定移除时间),但新安装请使用新名称与新变量名。
|
||||
|
||||
## 推荐安装流程
|
||||
|
||||
@@ -53,8 +53,13 @@ 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。
|
||||
|
||||
## 隐私边界
|
||||
|
||||
@@ -4,39 +4,54 @@
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
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]
|
||||
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
|
||||
```
|
||||
|
||||
## Remember → Understand → Monitor → Act
|
||||
## Remember → 图片文字 → Understand → Monitor → Act
|
||||
|
||||
TraceMemo 的工作方式可以概括为:
|
||||
|
||||
```text
|
||||
Remember → Understand → Monitor → Act
|
||||
Remember → 图片文字 → Understand → Monitor → Act
|
||||
```
|
||||
|
||||
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
|
||||
- **Remember**:读取并解析本机微信数据,建立聊天档案、普通搜索和导出。
|
||||
- **图片文字**:在本机识别图片里的文字,把截图、公告、报价图也变成可检索的内容。这一步不联网。
|
||||
- **Understand**:Knowledge、AI Search / 问问微信、群聊日报。需要模型时,只把完成这次任务所需的受控上下文交给 Provider。
|
||||
- **Monitor**:用成员快照对比发现群成员变化,产出成员退出事件。
|
||||
- **Act**:自动化规则把前面的步骤串起来(定时日报、退群通知);动作经过统一执行边界,并留下执行记录。
|
||||
|
||||
回答和动作结果都应能回到来源或记录核对。
|
||||
|
||||
## 退群监控
|
||||
|
||||
@@ -46,7 +61,9 @@ Remember → Understand → Monitor → Act
|
||||
Current Membership → Snapshot Diff → Member Event
|
||||
```
|
||||
|
||||
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
|
||||
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。监控关闭期间发生的变化,不会在重新开启后补报。
|
||||
|
||||
成员退出事件同时是「自动化」里「退群通知」规则的触发条件。
|
||||
|
||||
## 动作执行与审计
|
||||
|
||||
@@ -58,17 +75,20 @@ Feature → Policy → Gateway → Capability → Execution → Audit
|
||||
|
||||
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
|
||||
|
||||
这些动作统一由「自动化」管理,当前有三类规则:**@我生成日报**、**定时日报**、**退群通知**。发送目标支持当前群聊、文件传输助手、自己、指定好友,不是任意群发。
|
||||
|
||||
## 哪些步骤在本机
|
||||
|
||||
- 微信数据库读取与解析;
|
||||
- 聊天档案浏览和普通搜索;
|
||||
- Knowledge 索引与增量同步;
|
||||
- 图片文字索引:识别图片中的文字完全在本机进行,原始图片不会因为本地识别而上传;
|
||||
- 离线语音转写;
|
||||
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
|
||||
|
||||
## 哪些步骤可能调用外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
|
||||
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库,本机 OCR、离线语音转写和普通搜索也不会触发外发。
|
||||
|
||||
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
|
||||
|
||||
@@ -76,13 +96,15 @@ Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生
|
||||
|
||||
## 产品名词和用户任务的对应关系
|
||||
|
||||
| 用户想做什么 | 产品中可能看到的名称 |
|
||||
| ------------------------ | ---------------------------- |
|
||||
| 让 AI 找相关聊天 | AI Search、Retrieval |
|
||||
| 让答案能回到原消息 | Evidence、Citation |
|
||||
| 查看 AI 查找过程 | Search Trace |
|
||||
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
|
||||
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
|
||||
| 让微信机器人调用本机能力 | Agent Hub |
|
||||
| 用户想做什么 | 产品中可能看到的名称 |
|
||||
| ------------------------------ | ---------------------------- |
|
||||
| 让 AI 找相关聊天 | AI Search、Retrieval |
|
||||
| 让答案能回到原消息 | Evidence、Citation |
|
||||
| 查看 AI 查找过程 | Search Trace |
|
||||
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
|
||||
| 搜到截图、公告图里写过的文字 | 图片文字索引、本机 OCR |
|
||||
| 让日报、退群通知按规则自动执行 | 自动化、Policy、执行记录 |
|
||||
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
|
||||
| 让微信机器人调用本机能力 | Agent Hub |
|
||||
|
||||
先按任务使用,再在需要排查或开发集成时阅读术语。
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# TraceMemo Local HTTP API / Agent API 能力审计
|
||||
|
||||
本报告基于当前代码、文档、IPC、Renderer 调用和相关测试做静态审计,对应当前发布应用版本 `2.5.0`;不连接真实微信数据库,也不修改生产代码、API Center 或测试。
|
||||
|
||||
主要代码入口:[http-server.ts](../../src/main/http-server.ts)、[automation-rule-store.ts](../../src/main/services/automation-rule-store.ts)、[group-exit-monitor-service.ts](../../src/main/services/group-exit-monitor-service.ts)、[group-stats-service.ts](../../src/main/services/group-stats-service.ts)、[api.md](../agent/api.md)。
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
当前 HTTP 层声明了 **30 个 Method + Path 模板**:15 个 GET、12 个 POST、1 个 PATCH、1 个 DELETE、1 个 HEAD。共享 `LOCAL_API_ENDPOINTS` 定义 **14 个测试项**,但 Renderer 的 `API_ENDPOINTS` 实际只展示 **12 项**;个人微信能力和 `GET /scheduled-reports` 虽有共享定义,界面没有展示。界面也没有结构化 Query、媒体读取、定时日报写操作、退群监控、群统计、Automation 通用资源或执行日志查询。
|
||||
|
||||
当前不缺成熟的 Query primitive:结构化读取消息、Knowledge 搜索、消息前后文、会话概览和图片 OCR 搜索已经有 HTTP 契约。真正的能力缺口集中在 **监控配置、自动化规则通用管理、群员统计、执行历史、全局能力发现、发送状态和报告历史**。
|
||||
|
||||
几个影响后续设计的代码事实:
|
||||
|
||||
1. 定时日报已迁入 `AutomationRuleStore`。当前 `/scheduled-reports` 是旧 HTTP 契约的兼容投影,不是第二份活动规则存储;但它只表达“生成后发回来源群”,其他当前合法目标不会出现在该兼容 API 列表中。
|
||||
2. 退群监控只负责监测范围、快照和事件历史;退群后如何通知由唯一的 `leave_notification` Automation 规则负责。二者应分别建资源。
|
||||
3. 群统计 Service 足以提供活跃成员、当前沉默成员、时间范围、新鲜度和限制说明;它没有结构化的 former member 列表,也没有覆盖所有发言者的群总消息数。
|
||||
4. 现有微信 Action Gateway 有策略、审计和幂等骨架,但策略目前主要校验收件人,以及 Automation purpose allowlist;手动用户 purpose 默认可放行。它只发个人微信,现有 `/agent/send` 则走 iLink 的 `WechatSendGateway`。两者不能直接视为统一的 Agent 安全边界。
|
||||
5. HTTP 静态 GET handler 大多没有 method guard;对这些路径发 POST、PATCH 或 DELETE 仍会执行读处理。Automation store 的规则落盘失败会记录日志,但仍将内存中的规则返回为成功。
|
||||
|
||||
建议先做应用级能力发现、退群监控资源、严格校验后的 Automation CRUD/执行查询;发送和“立即执行”放到有 dry-run、明确确认、幂等键和 Action 审计的后续阶段。
|
||||
|
||||
## 2. Current API Inventory
|
||||
|
||||
以下按 `http-server.ts` 的路由分派和动态 route factory 盘点。静态读路由的 `Method` 是当前文档和产品语义的预期方法;实际接受方法的差异见本节末尾。
|
||||
|
||||
| Method | Path | 能力 | Read/Write/Execute | Service | API Center | 文档 | Agent 价值 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| GET | `/api/v1/health` | HTTP 与数据库 ready 状态;唯一免 Token 路径 | Read | `isReady()` | 是 | 是 | 高:连通性 |
|
||||
| GET | `/api/v1/current_time` | 本机时间、时区、日期 | Read | JavaScript `Date` | 是 | 是 | 中:相对日期换算 |
|
||||
| GET | `/api/v1/contact` | 联系人和群列表;`filter`、`type`;使用异步 hydration | Read | `chat-service.listContactsAsync` | 是 | 是 | 高:标识发现与 resolve 前置 |
|
||||
| GET | `/api/v1/chatroom` | 群聊列表;`keyword`;使用异步 hydration | Read | `chat-service.listContactsAsync` | 是 | 是 | 高:群标识发现 |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话;`limit` 默认 50 | Read | `chat-service.listRecentChat` | 是 | 是 | 高:导航/摘要 |
|
||||
| GET | `/api/v1/chatlog` | 按 talker 和时间读取原始消息;移除 `contentData.aeskey` | Read | `chat-service.listMessages`、`resolveMd5` | 是 | 是 | 高,但旧式、未做结构化分页 |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照;必填 `md5` | Read | `chat-service.getGroupSnapshot` | 是 | 是 | 高:成员身份解析 |
|
||||
| GET | `/api/v1/resolve` | 昵称、wxid、md5 解析为会话 | Read | `chat-service.resolveMd5` | 是 | 是 | 高;新资源宜返回稳定 ID 和歧义候选 |
|
||||
| POST | `/api/v1/report` | 接收完整结构化日报并导出 HTML/PNG;当前拒绝外部 `templateRef` | Write:本地文件 | `group-report-service.exportGroupReport` | 是 | 是 | 低/中:低层渲染契约,Agent 须先拼完整结构 |
|
||||
| POST | `/api/v1/agent/group-report` | 读群消息、调用 AI 生成群总结、导出 HTML/PNG | Execute:AI/本地文件 | `agent-group-report-service.generateAgentGroupReport` | 是 | 是 | 高但有模型费用/数据出站;结果不等同于报告历史记录 |
|
||||
| GET | `/api/v1/agent/status` | Agent Hub、connector、数据 API、数据库状态 | Read | `agentHubService.getStatus` | 是 | 是 | 中:只覆盖 Agent Hub,不是应用总能力 |
|
||||
| POST | `/api/v1/agent/send` | 通过 Agent Hub 连接器发送文字或媒体 | Execute:微信发送 | `agentHubService.testSend` → `WechatSendGateway`/iLink | 是 | 是 | 高但 R2;是测试入口,不含统一 Action policy/幂等确认 |
|
||||
| GET | `/api/v1/wechat-personal/send-capability` | 个人微信 text/image/voice 能力状态 | Read | `PersonalWechatCapabilityService`,由 `ScheduledReportApiService` 包装 | 否(共享定义有,界面未展示) | 是 | 高但只代表 personal,不代表 iLink |
|
||||
| GET | `/api/v1/scheduled-reports` | 列出旧 DTO 可表达的定时日报规则 | Read | `ScheduledReportApiService.list` → 规则投影 | 否(共享定义有,界面未展示) | 是 | 高但不完整:仅 `source_chat` 目标 |
|
||||
| POST | `/api/v1/scheduled-reports` | 创建旧型定时日报,来源群即发送目标 | Write:规则配置 | `ScheduledReportApiService.create` → `AutomationRuleStore` | 否 | 是 | 高;功能受旧 DTO 限制 |
|
||||
| GET | `/api/v1/scheduled-reports/{id}` | 单条旧型日报规则投影 | Read | `ScheduledReportApiService.get` → `AutomationRuleStore` | 否 | 是 | 中/高:只支持兼容投影规则 |
|
||||
| PATCH | `/api/v1/scheduled-reports/{id}` | 修改旧型日报规则 | Write:规则配置 | `ScheduledReportApiService.update` → `AutomationRuleStore` | 否 | 是 | 高但只能改旧字段/目标 |
|
||||
| DELETE | `/api/v1/scheduled-reports/{id}` | 删除旧型日报规则 | Write:删除配置 | `ScheduledReportApiService.delete` → `AutomationRuleStore` | 否 | 是 | 中;新 API 应标 R3 并防护系统规则 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/enable` | 启用规则 | Write:配置/未来执行 | `ScheduledReportApiService.setEnabled` → `AutomationRuleStore` | 否 | 是 | 高;启用后未来可能发送微信 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/disable` | 暂停规则 | Write:配置 | `ScheduledReportApiService.setEnabled` → `AutomationRuleStore` | 否 | 是 | 高 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/run` | 手动触发完整日报规则 | Execute:可能调用 AI、保存历史、发微信 | `ScheduledReportService.runScheduledReportNow` → `AutomationService` | 否 | 是 | 高但 R2;当前无 HTTP 幂等键/确认 |
|
||||
| GET | `/api/v1/scheduled-reports/{id}/executions` | 旧型 execution 历史和新 Automation execution 投影 | Read | `ScheduledReportService.listExecutions` | 否 | 是 | 高但旧响应模型/有限留存 |
|
||||
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 旧文档称复用 PNG 重发 | Execute 路由存在但当前固定 `501 not_supported` | `ScheduledReportApiService.retrySend` | 否 | **路径有,语义已失效** | 无:不能重发 |
|
||||
| GET | `/api/v1/media/{mediaId}` | 读取消息关联的图片二进制 | Read | `http-media-service.readImageMedia` | 否 | 是 | 高:图像证据 |
|
||||
| HEAD | `/api/v1/media/{mediaId}` | 图片资源存在性/响应头 | Read | `http-media-service.readImageMedia` | 否 | 否 | 低/中 |
|
||||
| GET | `/api/v1/query/capabilities` | Query Tool 支持的结构化操作、范围和上限 | Read | `LocalQueryApiService.capabilities` | 否 | 是(独立章节) | 高,但不是 TraceMemo 应用能力清单 |
|
||||
| POST | `/api/v1/query/messages` | 单会话、范围、时间、方向、类型等确定性消息读取 | Read | `LocalQueryApiService.messages` | 否 | 是 | 高:推荐 Query primitive |
|
||||
| POST | `/api/v1/query/search` | Knowledge 关键词检索,返回覆盖、新鲜度和 OCR 命中 | Read | `LocalQueryApiService.search` + `KnowledgeSearchService` | 否 | 是 | 高:必须读取 coverage/freshness |
|
||||
| POST | `/api/v1/query/message-context` | 通过 opaque `messageRef` 读取前后文 | Read | `LocalQueryApiService.context` | 否 | 是 | 高:稳定消息引用 |
|
||||
| POST | `/api/v1/query/conversation-overview` | 单会话范围的概览证据和 source coverage | Read | `LocalQueryApiService.overview` | 否 | 是 | 高:broad summary |
|
||||
|
||||
路由来源:[http-server.ts](../../src/main/http-server.ts#L212)、scheduled/query/media route factory(同文件 L404-L670)。总数是代码中声明的业务方法模板,不代表静态 handler 都正确拒绝其他动词:`/health`、`/current_time`、`/contact`、`/chatroom`、`/recent_chat`、`/chatlog`、`/group_snapshot`、`/resolve`、`/agent/status` 没有检查 `req.method`。这些路径携带错误动词仍会走同一 handler;特别是 `POST /health` 也绕过 Token 检查,因为鉴权例外按 pathname 判断。新/旧路由都应 fail closed 并对不支持的方法返回 405。
|
||||
|
||||
全局 `OPTIONS` 在路由和鉴权前处理;媒体额外支持 HEAD。非 health 路径要求 `Authorization: Bearer …`,但 `readBody` 没有大小上限。路由外层目前没有统一请求 schema、统一错误 envelope 或 request id。
|
||||
|
||||
## 3. Internal Capability Inventory
|
||||
|
||||
这里按产品能力追 Service → IPC/Renderer → HTTP → 外部 Agent,而不是按当前 API 名字扩展。
|
||||
|
||||
| 业务域 | 已有能力及实现 | IPC / Renderer | HTTP 现状 | Agent 结论 |
|
||||
|---|---|---|---|---|
|
||||
| Chat / Contact | 联系人、群聊、最近会话、解析、历史消息、群快照、消息周边上下文、媒体定位;`contact-resolution-service` 可精确匹配别名并返回歧义候选 | `db:getContacts`、`db:getGroupSnapshot`、消息查询/around 等,Chat/Contact/档案 UI | 旧 Reader routes + `/query/*` + 图片 `/media/{id}` | 已有 Read API 基础完整。新配置应使用 `m_nsUsrName` 对应的 wxid/roomId 等稳定 ID,不应把昵称当长期键 |
|
||||
| Query / Knowledge | `messages`、`search`、`message-context`、`conversation-overview`;Knowledge 索引覆盖/新鲜度;Image OCR 可进入 Knowledge 搜索和证据 | `knowledge:search/getStatus/startIndex/cancelIndex`、AI Search UI、`LocalQueryToolExecutor` | 四个 Query 操作均已 HTTP 化;`query/capabilities` 只描述 Query Tool | 没有需要重做的基础 Query primitive。缺的是统一应用 capability/status、更多外层筛选和 API Center 展示 |
|
||||
| Group Analytics | 活跃/沉默当前成员、每人 messageCount/lastMessageTime、窗口时间、memberCount、unattributed/system 消息、firstMessageTime、freshness/complete/limitations;索引未新鲜时最多等待 2 秒并如实降级 | `group-stats:getMemberStats`;聊天页群统计 UI | 无 | P1 候选。当前 query 要求群 `userMd5` + epoch 毫秒。former sender 只以限制文案给出数量,没有 formerMembers 数组/结构化计数;没有覆盖 former sender 的群总消息数字段,需扩 DTO 后再承诺 |
|
||||
| Group Exit Monitor | enabled/running/nativeMonitor 状态、监控 roomId 集合、lastChecked、unread、事件历史;每次回传最多 500 条,但完整事件历史 append-only 长期保存;可立即 check、改范围、启停、筛事件、清历史、mark read | `group-exit-monitor:*`;`GroupExitMonitorWorkspace` | 无 | P0。拆成 monitor state/config、events、check。`checkNow` 可能发现事件并触发自动通知,不是纯读操作;重启监控会重建快照基线,暂停期成员变化不会补报 |
|
||||
| Automation | 实际规则类型:`daily_report`、`scheduled_report`、`leave_notification`。`daily_report` 是现有消息触发条件/动作链,不是任意流程引擎;退群通知为固定 ID singleton。规则 CRUD、enable、执行记录读写均已有 Service/Store | `automation:*`;AutomationWorkspace 有规则、日志、定时执行、启停、删除和状态 UI | 没有通用 Automation API;仅 scheduled-report 兼容映射 | P0。使用 typed rule union;不要把内部任意 draft 原样开放。规则创建当前缺省 enabled=true,未知值会被归一化成默认;需 HTTP 严格校验,先 disabled + validate,再显式 enable |
|
||||
| WeChat Send / Action | `WechatSendGateway` 有 personal/iLink transport resolution、text/image/voice/file 统一类型及 Send Log;`WechatActionGateway` 做 capability preflight、Automation purpose allowlist、Action audit、幂等和 Automation 3 秒间隔 | `wechat-personal:send`、`sendGeneratedTtsVoice`、`wechat-action-log:list`、Agent Hub connector 相关 IPC/UI | `/agent/send` 只走 Agent Hub/iLink 测试发送;个人 capability 有独立 GET;两类日志没有 HTTP | 分 transport 公布 capability;R2 send 通过经审计的业务门面,不直接暴露底层 gateway。现有 Action Gateway 还不是普适安全策略:`triggerType=user` 不按 purpose 限制;普通 `/agent/send` 没传 idempotency key,也没有 Action audit |
|
||||
| Agent Hub | status、connector login/reconnect/disconnect、notification recipient/send、logs、conversation list/detail/clear、入站 inbox retry | `agent-hub:*`;Agent Hub UI | 仅 `/agent/status` 和 `/agent/send`;无 conversation/log HTTP | status 有只读价值。对话记录包含完整收发正文;inbox 包含 context token/raw items。Connector 生命周期、登录 QR/验证码、收件箱和通知 recipient 应保持 internal |
|
||||
| Reports / Templates | 手动 report render/export;AI group report;本地 Report History list/save/update template/delete;内置/已安装模板和市场 catalog/install/uninstall | `report:*`、`report-template:*`、`report-template-market:*`;Reports 与 Template Market UI | `/report` 低层 export,`/agent/group-report` AI 生成;没有 history/template API | P1:只读 Report History 元数据/资产可分离设计。现有 `listGeneratedReports` 会读取每张 PNG 为 base64,并返回结构快照和本机绝对路径,不可原样直出。模板目录可读列入 P2;安装/卸载涉及网络与本地包写入,不宜第一批开放 |
|
||||
| Recall Archive | 后台监听撤回变化,最多按会话存归档消息/撤回记录;chat-service 将 archive merge 到历史读结果 | 没有独立 CRUD IPC;设置开关和消息渲染 | 没有独立 archive API;旧 `/chatlog` 可能随底层消息返回 `recalled` 标记;Query DTO 未声明 recalled 字段 | 不开放原始 archive 管理。后续 Query 应明确返回 `recalled`/来源,避免把已撤回归档当普通消息证据 |
|
||||
| OCR / Image Insight | System OCR 本地识别;image-text-index status/count/start/pause/resume/cancel/clear/repair;Image Insight 读/解密图片并可调用 AI Provider | `system-ocr:*`、`image-text-index:*`、`image:*` IPC;Search/Report UI | OCR 派生文本可通过 `/query/search` 得到;索引管理、单图 AI 分析无 HTTP | 已有搜索能力可用。状态可纳入 capability/status;索引删除、key/decoder 配置、任意图像 AI 分析涉及成本、私密图片和索引破坏,不列第一批 |
|
||||
| 系统 / 数据 / 其他 | account discovery、DB key 管理、数据库 connect/root 重开、设置写入、cache summary/clear、app update、voice/TTS、export/import、Reader Skill 本地安装信息 | 多组 IPC;Settings、Cache、Export、Update、Voice UI | 无相应 HTTP API | 只读脱敏运行状态可按需求列 P2;DB key/root、通用 settings patch、cache 清理、任意文件路径、更新安装、TTS synthesis 等保持 internal |
|
||||
|
||||
### A. Chat / Contact 与 ID 语义
|
||||
|
||||
- `/contact`、`/chatroom` 改用 `listContactsAsync`,因为 macOS Session 可能只有原始 wxid/chatroom id,需要 hydrate 显示名;`ScheduledReportApiService` 却使用同步 `listContacts()` 解析群名。稳定 `talker` 可直接解析,但名称输入在需要 hydration 的运行时可能失败/退化。这是可复用 adapter 应统一异步解析的理由。
|
||||
- ID 现在不是一个口径:旧 Query 的 `scope.conversationId`/`target` 解析为 `Contact.md5`;`group-stats` 传 `userMd5`;监控用 `roomId`(`xxx@chatroom`);新的 scheduled automation 用 `sourceConversationId`(wxid/roomId);老 HTTP 路由混用昵称、wxid、md5。保持已有 Reader 契约不动,新 API facade 应统一对外 canonical `conversationId`(底层当前联系人的稳定 username/wxid 或 roomId),并在 main adapter 转为服务所需 md5。名称只做 resolve,不持久化到规则。
|
||||
- `chatlog` 时间边界是 Unix 秒,Query `absolute` 内部也是秒,而 group stats IPC 是 epoch 毫秒;新 Agent 资源建议用带时区 ISO-8601 输入/输出,并在 facade 单点转换。
|
||||
- `/query/messages` 有 200 上限,`messageRef` 是 opaque 稳定引用;图片 OCR 文字和 Coverage 分开呈现。`/chatlog` 则支持旧 talker/time 风格但读取结果没有同等结构边界;作为兼容 Reader 保留,不作为新 Agent 配置/分析的默认接口。
|
||||
|
||||
### B. Group Exit Monitor 与 Leave Notification
|
||||
|
||||
`GroupExitMonitorService` 的真实 IPC 有 `getState`、`setEnabled`、`setGroups`、`checkNow`、`listEvents`、`clearEvents`、`markRead`。事件是群成员差异事实,包含 roomId、member wxid/name、previous/current count、detectedAt;monitor state 中 `events` 只是最近 500 条快照,`totalEventCount` 对应完整内存历史。
|
||||
|
||||
`AutomationService.handleGroupExit` 只处理 `BUILTIN_LEAVE_NOTIFICATION_RULE_ID` 对应的 singleton 规则。规则另有 `notifyScope` / `notifyRoomIds` 二次范围、target、template。Agent 配“监控 A/B/C”需改 monitor 范围;配置通知目标/通知哪些被监控群则另改这条 leave notification automation。两者不能合并为 `/monitors/{id}/notify`。
|
||||
|
||||
### C. Automation 与 Scheduled Report
|
||||
|
||||
`AutomationRuleStore` 的真实方法有 `listRules/getRule/createRule/updateRule/saveLeaveNotificationRule/deleteRule/setRuleEnabled`;`AutomationExecutionLogService` 提供 `list/record/clear/countSince`。执行日志最多留存 200 条,clear 属于破坏性操作。规则在 `{userData}/automation/rules.json` 中 JSON 持久化。
|
||||
|
||||
`scheduled-report-service.ts` 的调度来源是 `automationRuleStore.listRules()`,执行交给 `AutomationService.executeScheduledRule()`,execution 从 Automation Log 投影。迁移后的旧 `tasks.json`/`executions.json` 是只读历史存档。旧 HTTP API 通过 `ScheduledReportApiService` 转换旧 DTO;创建、修改、删除、启停最终也是读写 `AutomationRuleStore`。所以正确方案是保留兼容 facade,并建立 Automation canonical API,不要继续增加第二个 scheduled-report store。
|
||||
|
||||
旧 facade 的限制:只列/操作可投影为 `target.type === 'wechat_group'`、且目标等于来源群的规则。如今 scheduled automation 支持 source_chat/self/file_transfer/contact,故通过新 UI 创建为文件传输助手或联系人目标的规则,会从旧 `/scheduled-reports` 列表隐藏。旧 API 输入 schema 也不能表示完整 scheduled config(成员名、消息类型、模板、timeout、postfix 等)。
|
||||
|
||||
写 API 前还需处理 `AutomationRuleStore` 的归一化和持久化契约:未知 ruleType 会降成 `daily_report`,大部分错误枚举会静默落安全默认;缺省 enabled 是 true;`persist()` catch 写盘错误后只记 warning,Store 仍返回创建/更新后的对象。HTTP adapter 必须先 strict validate,且 Store 需要可观察的持久化结果,不能把内存态冒充成功。
|
||||
|
||||
### D. Group Analytics 确认项
|
||||
|
||||
`GroupStatsService.getMemberStats` 已有可直接复用的核心计算;接口具体有:
|
||||
|
||||
- 当前群成员:`memberCount`、`activeMembers`、`silentMembers`、各活跃成员 `messageCount`/`lastMessageTime`;
|
||||
- 查询窗口:`startTime`、`endTime`(epoch ms)、`firstMessageTime`;
|
||||
- 数据完整性:`freshness = fresh|stale|unknown`、`complete`、`limitations`;
|
||||
- 诊断:`unattributedMessages`、`excludedSystemMessages`。
|
||||
|
||||
成员名单是当前成员集合;知识库统计的 sender 不在当前集合时被排除,只在 `limitations` 中增加“另有 N 位窗口内发言者已不在当前群成员名单”。Service 不返回其身份/每人消息数,也没有 `totalMessageCount`。若 Agent 需要“前成员榜”或全群消息数,需要先扩展 Service/shared type;不能由 API adapter 从 limitation 文案反解析。
|
||||
|
||||
## 4. API / Docs / API Center Drift
|
||||
|
||||
| 项目 | 代码事实 | 漂移/影响 |
|
||||
|---|---|---|
|
||||
| HTTP、共享定义与界面列表 | HTTP 有 30 个 method/path 模板;`LOCAL_API_ENDPOINTS` 定义 14 项,Renderer `API_ENDPOINTS` 实际展示 12 项 | 16 个 HTTP 操作模板没有共享定义;另有 2 个已定义项(个人微信能力、定时日报列表)没有展示。界面仅呈现 12/30 项,不能作为完整 API catalog |
|
||||
| Scheduled Report 展示 | 共享定义只有 `GET /scheduled-reports`;该项本身也未进入 Renderer 列表 | POST 和 task action 不显示,GET 列表也不显示;Agent 在 API Center 里无法发现这组 API |
|
||||
| WeChat Capability 展示 | 共享定义有 `GET /wechat-personal/send-capability`;Renderer 列表未包含它 | API Center 看不到个人微信发送能力状态,用户可能误把 Agent Hub 状态当成完整发送能力 |
|
||||
| Query 展示 | Query 文档在 `api.md` 的独立 LLM-friendly 章节,Service/HTTP 实现完整 | API Center 看不到;用户可能误认为 Reader API 仍只有旧 chatlog |
|
||||
| Media 方法 | `/media/{mediaId}` 支持 GET、HEAD | 文档仅列 GET;API Center 都未列 |
|
||||
| Retry Send | 文档表称 retry-send“复用已有 PNG 重试发送” | `ScheduledReportApiService.retrySend()` 当前无条件抛 `501 not_supported`;integration/unit tests 也未覆盖 retry 路由的这项现状 |
|
||||
| Scheduled Report 完整性 | 旧 facade 只 project `source_chat` | UI 可保存的其他 scheduled target 会从旧 API list/get 隐藏;不是两份存储,但旧 API 不是 Automation API 的完整别名 |
|
||||
| Health 版本 | `/health` 固定返回 `version: "1.0.0"` | 与当前 package version `2.5.0` 不同,Agent 无法据此判断应用版本 |
|
||||
| HTTP 动词 | 九个静态 GET 语义路由无 method guard | POST/PATCH/DELETE 等也可能调用读取逻辑;`/health` 任意 method 均免 Token。测试目前未锁定统一 405 契约 |
|
||||
| 请求/错误 schema | JSON parsing 和错误形状分散:通用 `sendError`、ScheduledReport 专用 error、Query status body、业务自身 result | Agent 要写多套解析逻辑;共享 API schema 和统一错误 code 不存在 |
|
||||
| 命名 | `/contact`、`/chatroom`、`/recent_chat`、`/group_snapshot` 与 `/scheduled-reports`、`/query/*`、`/agent/*`、`/wechat-personal/*` 并存 | snake_case 旧路径、资源路径和“Agent 为业务 owner”的命名混杂;新接口不能继续沿用此漂移 |
|
||||
|
||||
文档 [api.md](../agent/api.md#L35) 基本列出当前 HTTP 路径,Query 在后续单独说明;除 HEAD 外没有发现漏写的当前业务路径,但 retry-send 的成功语义过期。API Center 的来源是单独的 [local-api-test.ts](../../src/shared/local-api-test.ts) 和 [apiEndpoints.ts](../../src/renderer/src/features/api-center/model/apiEndpoints.ts),没有从 HTTP route/schema 派生。测试现有 `local-api-auth` 覆盖鉴权、媒体、部分 Query 和 Agent send;`scheduled-report-api` 覆盖旧生命周期;`local-api-contact-search` 覆盖 hydrate。它们没有自动比对 HTTP route、文档、Catalog 三者,也没有覆盖全部 method guard 和 retry-send。
|
||||
|
||||
## 5. Candidate API Matrix
|
||||
|
||||
风险按本任务口径:R0 只读;R1 本地配置/应用状态修改;R2 微信发送、AI/provider 调用等外部副作用;R3 删除或清空不可轻易恢复的数据。R1 不代表没有后续行为:enable 一条定时规则会武装未来的 R2 执行。
|
||||
|
||||
| Capability | 当前实现 | 当前 API | 建议 | Agent 用例 | Risk | Priority |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 联系人/群/会话 resolve | Chat Service + Contact Resolution | 有旧 routes;Query 内 resolve | 保留旧路由;新 resource 返回稳定 ID、歧义候选 | 查找群并取得 roomId | R0 | P0(复用) |
|
||||
| 结构化消息/搜索/上下文/概览 | LocalQueryApiService + Knowledge | `/query/*` | 保持契约;加 route schema/catalog,后续可升级稳定 ID | 查聊天、关键词/OCR、补上下文 | R0 | P0(复用) |
|
||||
| 应用 capability discovery | 各 Service 能回答局部状态 | 无;`query/capabilities` 仅 Query Tools | 新 `GET /capabilities`,区分 supported/available/reason/operations | 发现自动化、监控、统计、发送 transport | R0 | P0 |
|
||||
| Group Exit Monitor 状态/范围 | GroupExitMonitorService | 仅 IPC | GET state + PATCH enabled/roomIds | 查看监控、监控/停止一个群 | R0/R1 | P0 |
|
||||
| Group Exit events | Monitor JSONL + listEvents | 仅 IPC | GET 带 stable roomId/time/cursor/limit | 最近 7 天谁退群 | R0 | P0 |
|
||||
| 手动检查退群 | checkNow 会扫描并触发事件 handler | 仅 IPC | 有外部通知时按 R2 操作开放,先 validate effects + confirm | 立即检查一次 | R2 | P1 |
|
||||
| Automation 规则 CRUD | AutomationRuleStore | 通用 IPC;HTTP 仅旧 scheduled facade | typed union CRUD;create disabled;validate 再 enable;保护 singleton/system rules | 创建、列出、修改、暂停自动化 | R1/R3(delete) | P0 |
|
||||
| Automation validation/dry-run | 现有编辑器 preview 分散;无通用 validator API | 无 | `POST /automations/validate`;不落盘、不发送 | 确认群、目标、模板、下次运行和能力 | R0 | P0 |
|
||||
| Automation execution history | AutomationExecutionLogService,最多 200 条 | schedule 专属旧投影 | 规范化 Automation execution 读接口;清日志不开放第一批 | 查看失败、按 rule 过滤 | R0/R3(clear) | P0 |
|
||||
| Group member stats | GroupStatsService | 仅 IPC | 按稳定 group ID + ISO window 读统计;先补 former/total 语义 | 近 30 天活跃榜 | R0 | P1 |
|
||||
| WeChat capability | personal capability service;Agent Hub status | personal GET + Agent status | 新全局 capability 含 personal/iLink 和内容能力;旧路由保留 | 检查发送当前是否可用 | R0 | P0 |
|
||||
| 手动微信发送 | WechatSendGateway + Action Gateway | `/agent/send` iLink test send | 新 send command 经受限 Action facade,强制 stable recipient、confirm、idempotency | 文件助手测试消息 | R2 | P1 |
|
||||
| Send Log / Action audit | Send Log 500 条;Action audit 500 条;IPC action-log | 无 HTTP | 分层只读分页,preview 脱敏;按 executionId/requestId 关联 | 查最近发送失败、审计规则动作 | R0 | P1 |
|
||||
| Agent Hub status | AgentHubService.getStatus | `/agent/status` + IPC | 保留 alias,新资源名归 `/agent-hub/status`,与 app capabilities 分开 | 查 Hub/connector online | R0 | P1(复用) |
|
||||
| Agent Hub 对话内容 | Conversation Store,最多 50 会话×500 条 | 仅 IPC | 默认为 Internal;若产品确认需要,另做显式 opt-in、分页/时间过滤 | 查看机器人与某人的对话 | R0(高隐私) | 不建议第一批 |
|
||||
| AI 群日报 | AgentGroupReportService + export | `/agent/group-report` | 保留兼容;未来先 validate model/range/group/data egress,再异步 job | 生成临时总结图片 | R2(provider/本地文件) | P1 |
|
||||
| 日报历史 | ReportHistory Service,IPC CRUD | 无 | 分页 metadata DTO;图片 asset 单独下载;不返回 base64/路径/完整 snapshot | 昨天生成过哪些日报 | R0 | P1 |
|
||||
| 模板列表 | Template Service + market catalog | 仅 IPC | 仅已安装模板只读列表列 P2 | 有哪些日报模板 | R0 | P2 |
|
||||
| 模板安装/删除/历史改版 | Template Service/Market + Report History | 仅 IPC | 不开放通用 HTML/路径写入;将来单独授权且保留校验 | 安装或修改模板 | R1/R3 | Maybe/P2 |
|
||||
| Recall archive 查询 | 内部 archive merge 到历史消息 | 无独立 API | 不单独开放磁盘 Archive;给 Query 增 `recalled` 来源标记 | 找被撤回消息 | R0(敏感/语义风险) | P2 |
|
||||
| Image OCR index 操作 | image-text-index service | IPC(含 clear/repair) | coverage 状态可汇入 capabilities/status;不让 Agent 操作 clear/reset | 查 OCR 覆盖 | R0/R1/R3 | P2 |
|
||||
| AI image insight | ImageInsightService 读/解密图片并调 vision provider | IPC | 不暴露任意 hash/message AI 分析,除非有成本/隐私授权 | 理解群图片 | R2 | 不建议第一批 |
|
||||
| DB key、根目录、settings、cache | 多个设置/DB/cache Service | IPC/UI | 禁止通用 settings patch / 文件路径 / DB key API;只加白名单状态字段 | 修改本机数据库、安全设置 | R1/R3 | 不建议开放 |
|
||||
| Connector 生命周期/inbox | AgentHubService + WechatInboundInbox | 仅 IPC/内部 | connector 登录、验证码、QR、inbox、context token 不对 Agent 暴露 | 重连或直接拿入站 token | R1/R2 | 不建议开放 |
|
||||
|
||||
## 6. P0 Recommendation
|
||||
|
||||
第一批目标是“让 Agent 能配置和核验 TraceMemo,但不意外发消息”。建议只包括:
|
||||
|
||||
1. `GET /api/v1/capabilities`:应用级 capability,不与现有 `/query/capabilities` 合并。返回版本、DB/readiness、supported vs available、不可用原因和依赖;发送分 personal/iLink 与 text/image/voice 能力。
|
||||
2. Group Exit Monitor:读取状态、显式配置 monitored roomIds、读取历史事件。`PATCH` 只接 canonical 群 ID,拒绝不存在/非群 ID;修改范围响应明确显示后台 baseline/check 状态。`check` 先列 P1,因为它可能启动退群通知发送。
|
||||
3. Automation typed CRUD:列/读规则、创建 disabled 规则、更新、显式启停、执行历史读取。Leave Notification 仍使用固定 singleton id,不允许创建重复规则;拒绝未知字段/未知 enum,而不是靠 `normalizeRuleDraft` 静默修正。
|
||||
4. `POST /automations/validate`:验证目标群、稳定通知 recipient、模板变量、report/template 配置、send capability 和 nextRunAt;只返回 plan,不落盘、不发送。
|
||||
5. 运行状态与错误契约:一致的 405、最大 body、请求 ID、错误 envelope;这是任何新 Agent 写接口前的 foundation,不是大规模权限系统。
|
||||
|
||||
Agent 实现示例(概念流程):
|
||||
|
||||
- “监控 A/B/C 退群”:resolve 三个群为 roomId → validate scope → PATCH monitor group IDs。
|
||||
- “A 群有人退出就通知文件助手”:读取 monitor scope 和 singleton leave rule → validate 类型/notify scope/target → 更新规则但保持 disabled → 用户/Agent 明确 enable。监控与 leave-notification 是两份正交配置。
|
||||
- “每天 20:00 生成产品群日报”:resolve sourceConversationId → validate scheduled rule(包含 target/transport/模板/时区/next run)→ 创建 disabled → 显式 enable。旧 `/scheduled-reports` 无法表达所有当前 config,不承担新 Agent CRUD。
|
||||
- “昨天哪些自动化失败”:读 automation executions,按本机 timezone/UTC offset 和 status 查询,不清理日志。
|
||||
|
||||
## 7. Proposed Resource Model
|
||||
|
||||
采用业务 capability 资源,HTTP server 只负责 transport、auth、body、统一错误;每个 domain route 调用独立的 main-process API facade/Service adapter。Facade 复用当前 Service/Store,不把 UI IPC 当 HTTP RPC 转发层。
|
||||
|
||||
| Resource | 职责 | 现有路径的处理 |
|
||||
|---|---|---|
|
||||
| `system` | health、版本、应用级 capabilities、运行状态 | `/health` 保留;新增 `/capabilities`;`query/capabilities` 不改语义 |
|
||||
| `contacts` / `groups` | 稳定 ID 列表、resolve、群成员快照/统计 | `/contact`、`/chatroom`、`/resolve`、`/group_snapshot` 保留兼容 |
|
||||
| `query` | 消息/搜索/上下文/概览证据 | 现有 `/query/*` 保持;query ID 口径升级需兼容 reader skill |
|
||||
| `monitors` | 退群监控范围、启停、事件、显式 check | 新 `/monitors/group-exits`,与通知规则分离 |
|
||||
| `automations` | 规则 typed CRUD、validate、启停和运行 | 新 `/automations` 是 canonical HTTP resource;底层仍由 `AutomationRuleStore` 存储 |
|
||||
| `executions` | 跨 Automation/Action/Send 的只读运行视图 | 新 `/executions` read model,不合并各自写存储 |
|
||||
| `wechat` | transport capabilities、受控 send command、Send Log/Action audit | `/agent/send` 与 `/wechat-personal/send-capability` 保留兼容 |
|
||||
| `reports` | report history 元数据/asset;installed templates read-only | `/report` 与 `/agent/group-report` 保留为不同兼容操作 |
|
||||
| `agent-hub` | Hub/connector 状态;conversation API 默认 internal | `/agent/status` 保留 alias,不复用 app capability |
|
||||
| `developer` | 高级 raw request tester 与诊断 | API Center 的 tester;不作为 Agent 业务 API |
|
||||
|
||||
共享资源原则:新 API 输入以 stable id 为主、名字只用于 resolve;所有写请求 strict validate;时间对新资源用 offset ISO-8601;分页使用 `limit` + `cursor`;成功/失败使用一个 typed envelope;不直接返回 app userData path、token、context token、AES key 或原始 transport payload。
|
||||
|
||||
## 8. Safety Model
|
||||
|
||||
### 当前边界
|
||||
|
||||
- 默认监听 `127.0.0.1:6131`,但 host/port 由设置和 `api:start` 调用传入,API Center 会警告非 loopback。CORS 只允许 loopback Origin,但不带 Origin 的 curl/Agent 请求仍可用 Token;CORS 不是本地进程授权边界。
|
||||
- `/health` 公开,其余 endpoint 共用单一 Bearer Token。Token 为 32 random bytes、safeStorage 加密存储并设 `0600`,没有 read/config/send scope。拿到 token 即可读取聊天,也能建/删/启停规则、立即发送。
|
||||
- HTTP `/agent/send` 的消息进入 `WechatSendGateway`,因此有低层 Send Log;但没有 `WechatActionGateway` 的业务 Action audit/策略决策,也没有调用方 Idempotency-Key。Personal capability 路由只报告个人微信状态。
|
||||
- `WechatActionGateway` 的请求包含 `purpose`、`triggerType`、recipient、content、`idempotencyKey`、`executionId`;Automation trigger 有 purpose allowlist,sender capability 会先检查,审计记录会保存 content preview/hash。当前 `evaluateWechatActionPolicy` 对 `triggerType=user` 不做 purpose allowlist,`shouldUseAiPolicy` 固定 false;幂等只对显式 key 或历史特定 scheduled request 生效。它目前只发个人微信,不能直接替换 iLink send。
|
||||
- Action Audit 和 Send Log 各自上限 500;Automation Execution Log 上限 200。三类日志粒度不同,不是重复的同一事实。
|
||||
|
||||
### 分级建议
|
||||
|
||||
| 风险 | 操作 | 建议控制 |
|
||||
|---|---|---|
|
||||
| R0 | 查询聊天/成员/事件/状态/统计/日志/报告元数据 | 保留本地 Token;响应明确 scope、coverage、freshness、隐私字段 |
|
||||
| R1 | 修改监控群、Automation 配置、启停、模板设置 | typed validation、dry-run plan、显示持久化成功;enable 需确认它武装未来发送 |
|
||||
| R2 | 微信发送、立即执行日报/退群通知 check、调用 AI Provider 生成报告 | 明确 recipient 和内容/规则;`Idempotency-Key` 必填;统一 Action policy + Action audit + Send Log;重复请求回放原结果;返回发送状态 |
|
||||
| R3 | 清理退群事件、删除 report、清空执行/发送/Action 日志、清 cache/index、删规则 | 初始不开放;如以后开放,独立权限、预览计数、可恢复备份/本地确认,不接受批量 wildcard |
|
||||
|
||||
现有 Bearer Token 不足以支撑“查询、配置、发送、清理”都对一个不受信 Agent 开放。近期不必造完整账户系统,但应先修路由 method/body/validation,提供默认只读或 disabled 配置工作流;后续可加入多个 named token + scope(`read`, `configure`, `send`, `destructive`),R2 请求确认和 durable idempotency。风险分类也需承认本地 artifact write(如 `/report` 导出)不是配置本身,可先按 R1 local-write 处理。
|
||||
|
||||
## 9. Compatibility Plan
|
||||
|
||||
1. 不 rename/remove 任何现有 route。Reader Skill 依赖旧 contacts/chatlog/media 路径;新 structured query 已经是更合适的 Agent query,但两者并行。
|
||||
2. 新 `/automations` 读写同一 `AutomationRuleStore`。旧 `/scheduled-reports*` 改为明确标注 Deprecated 的 compatibility adapter;维持现有 request/response shape 和 source_chat 子集,不维护第二个任务存储。响应可加 `Deprecation` header/文档说明,未定 sunset 前不返回 breaking error。
|
||||
3. `/scheduled-reports` 的投影不能假装覆盖所有 scheduled automation。旧 list/get 只反映 source_chat;新 clients 必须迁到 `/automations?type=scheduled_report`。旧 `retry-send` 保留返回 501,文档明确废弃;不能伪造已发送成功。
|
||||
4. `/agent/send` 继续表示现有 iLink/Agent Hub 测试发送。新 `/wechat/send` 必须先明确 transport/recipient schema,再通过能统一 personal+iLink 的受控 Action facade;如果不能保留旧发送语义,就将旧路径作为 adapter 而不是简单 alias。
|
||||
5. `/wechat-personal/send-capability` 保持 personal-only 兼容 view;新全局 capability 返回 transport map。应用能力 `/capabilities` 和 Query Tool 的 `/query/capabilities` 各自有清晰不同的契约。
|
||||
6. 同一 shared contract/catalog 应供 HTTP 验证、API Center、文档和 route contract tests 使用;把实际路由、文档和 API Center 三者 drift 变成测试失败,而不是发布后人工发现。
|
||||
|
||||
## 10. Proposed Phase Plan
|
||||
|
||||
### Phase A — HTTP Contract / Facade
|
||||
|
||||
给现有 routes 加明确 method guard、body size limit、严格 shared request schema、统一错误 envelope/request id;补 `AutomationRuleStore` 写盘成功/失败结果;建立稳定 conversation ID adapter 和 route contract tests。保留 raw Node HTTP,不需要为第一阶段换 web framework。
|
||||
|
||||
### Phase B — Read + Validation P0
|
||||
|
||||
增加应用 `/capabilities`、monitor state/events、automation rules/executions 读取、`/automations/validate`、group stats adapter。monitor `check` 因可能触发 notification 暂留 P1 或先加 side-effect confirmation。把新资源路径、schema、风险元数据接进 EndpointCatalog/生成文档。
|
||||
|
||||
### Phase C — Automation Configuration
|
||||
|
||||
开放 disabled create、PATCH、启停和 DELETE 防护;退群通知专用 singleton upsert 接入同一规则资源;scheduled report 使用真实完整 config;旧 scheduled API 只做 compatibility projection。先做 dry-run 再允许 enable。
|
||||
|
||||
### Phase D — Side Effects / Execution Read Model
|
||||
|
||||
扩展一层统一 `Action` facade 支持 iLink + personal transports,并有 per-purpose policy、recipient allowlist/validation、确认语义、强幂等、Action audit 到 Send Log correlation。再开放 send/check/run;按需增加 `/executions` read projection,不合并底层日志存储。
|
||||
|
||||
### Phase E — API Center
|
||||
|
||||
Overview、Query、Monitors、Automations、WeChat Actions、Reports、Developer 分区;按业务任务做 schema-aware 表单/效果预览,Raw Request Tester 留在 Developer。展示 Token scope/host 范围/transport capability,而不只是固定 URL 测试器。
|
||||
|
||||
## 11. Concrete Endpoint Proposal
|
||||
|
||||
下表是下一阶段建议契约,不表示当前已实现。新写 API 应统一错误:`{"error":{"code":"...","message":"...","details":{...}},"requestId":"..."}`。时间使用带 offset 的 ISO-8601;接口只接受 stable IDs。
|
||||
|
||||
| Method | Path | Request → Response | Risk | Underlying Service |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/v1/capabilities` | 无 → app version/readiness + `query`,`groups.memberStats`,`groupExitMonitor`,`automations`、每种 `wechat.transport/content` 的 `supported/available/reason` | R0 | 新薄 facade 汇总 LocalQuery、GroupStats、Monitor、AutomationStore、personal capability、AgentHub status |
|
||||
| GET | `/api/v1/monitors/group-exits` | 无 → `{enabled,running,monitoredConversationIds,lastCheckedAt,eventCount}` | R0 | `GroupExitMonitorService.getState` |
|
||||
| PATCH | `/api/v1/monitors/group-exits` | `{enabled?,monitoredConversationIds?}` → 保存后的 state;监控 ID 必须 resolve 到现有群 | R1 | `setEnabled` / `setMonitoredRoomIds` |
|
||||
| GET | `/api/v1/monitors/group-exits/events?conversationId=&since=&until=&limit=&cursor=` | ISO 时间和稳定群 ID → `{events,nextCursor}`,事件显式 `eventId`、group/member、counts、detectedAt | R0 | `GroupExitMonitorService.listEvents`;为无 cursor 的现有 list 加稳定分页 adapter |
|
||||
| POST | `/api/v1/monitors/group-exits/check` | `{confirmSideEffects:true}` + `Idempotency-Key` → checkedAt、新事件数、notification execution refs | R2 | `checkNow`;因检查可发现事件并调用 leave-notification Automation,不应标成纯读 |
|
||||
| GET | `/api/v1/automations?type=&enabled=` | 无 → typed rules page;包括 singleton leave rule | R0 | `AutomationRuleStore.listRules` |
|
||||
| POST | `/api/v1/automations` | typed `AutomationRuleDraft`,create 默认 `enabled:false` → `{rule}` | R1 | `AutomationRuleStore.createRule`(需先强化 strict validation/persist result) |
|
||||
| GET | `/api/v1/automations/{ruleId}` | 无 → `{rule}` | R0 | `AutomationRuleStore.getRule` |
|
||||
| PATCH | `/api/v1/automations/{ruleId}` | typed partial config → `{rule}`;`ruleType` 不可变 | R1 | `AutomationRuleStore.updateRule` |
|
||||
| DELETE | `/api/v1/automations/{ruleId}` | 无 → `{deletedId}`;默认拒绝 builtin/system singleton 删除 | R3 | `AutomationRuleStore.deleteRule` + protected-id policy |
|
||||
| POST | `/api/v1/automations/validate` | typed draft → `{valid,normalizedDraft,issues,effects,resolvedTargets,nextRunAt,capabilities,validationId}`;不保存、不发送 | R0 | 新 validator adapter:contact resolve、template validator、capability services、schedule pure functions |
|
||||
| POST | `/api/v1/automations/{ruleId}/enable` | `{validationId,confirmFutureEffects:true}` → `{rule}`;validation hash 必须匹配当前规则 | R1(武装未来 R2) | `AutomationRuleStore.setRuleEnabled` + validator |
|
||||
| POST | `/api/v1/automations/{ruleId}/disable` | 无 → `{rule}` | R1 | `AutomationRuleStore.setRuleEnabled` |
|
||||
| POST | `/api/v1/automations/{ruleId}/run` | `{confirmSideEffects:true}` + `Idempotency-Key` → `{execution}` | R2 | `AutomationService.executeScheduledRule`;只对具有 `run` 语义的 rule type 开放 |
|
||||
| GET | `/api/v1/automations/executions?ruleId=&status=&trigger=&since=&until=&limit=&cursor=` | 无 → `{executions,nextCursor}` | R0 | `AutomationExecutionLogService.list`;需加 timestamp/filter/cursor adapter |
|
||||
| GET | `/api/v1/groups/{conversationId}/member-stats?start=&end=` | ISO 时间窗口 → active/silent/counts/freshness/complete/limitations;未来要 former members 则先扩 shared result | R0 | `GroupStatsService.getMemberStats`;adapter 把 canonical roomId 转 md5 |
|
||||
| GET | `/api/v1/wechat/capabilities` | 无 → personal/iLink 分 transport 状态与内容能力 | R0 | `PersonalWechatCapabilityService` + `WechatSendGateway.hasIlinkSender`/Agent Hub connector health |
|
||||
| POST | `/api/v1/wechat/send` | `{recipient:{type,id},content:{type:"text",text},confirm:true}` + 必填 `Idempotency-Key` → `{actionId,status,transport,sendLogRef}` | R2 | 扩展后的 `WechatActionGateway`/统一 Action facade;不得裸调 `WechatSendGateway.send` |
|
||||
| GET | `/api/v1/wechat/send-logs?status=&since=&limit=&cursor=` | 无 → 只读、脱敏 Send Log page | R0 | `WechatSendGateway.listSendLog` / `WechatSendLogService.list` |
|
||||
| GET | `/api/v1/wechat/action-logs?executionId=&status=&since=&limit=&cursor=` | 无 → 业务 Action 审计 page;preview 默认截短/可省略 | R0 | `WechatActionLogService.list` / `WechatActionGateway.listAuditRecords` |
|
||||
| GET | `/api/v1/executions?source=&status=&since=&until=&limit=&cursor=` | 无 → 统一只读 projection,每项保留 source/executionId/trigger/action/status/error/times/correlation IDs | R0 | 组合 `AutomationExecutionLogService`、Action audit、Send Log;不合并其存储 |
|
||||
| GET | `/api/v1/reports/history?groupId=&since=&until=&limit=&cursor=` | 无 → 仅元数据分页,不带 `generatedImage`、本地绝对路径或完整 report snapshot | R0 | `listGeneratedReports` 经 summary adapter,最好先让 Service 支持 metadata-only |
|
||||
| GET | `/api/v1/reports/history/{reportId}/image` | 无 → PNG binary | R0 | Report History 的 file resolver;仅按内部 report id 解析,不接收任意路径 |
|
||||
| GET | `/api/v1/report-templates` | 无 → 已安装模板的 stable id/name/version/available 列表 | R0 | `reportTemplateService.list` |
|
||||
| GET | `/api/v1/agent-hub/status` | 无 → hub/connector/dataApi/dbReady 的脱敏状态 | R0 | `AgentHubService.getStatus`;`/api/v1/agent/status` 保留 alias |
|
||||
|
||||
`POST /wechat/send` 建议先只开放 text + 明确目标;image path/url、voice 和 file 类型各自扩大本机文件/外传风险,需独立 validation/permission,不从现有任意 `msg` 输入自动继承。
|
||||
|
||||
## 12. Do Not Expose
|
||||
|
||||
- 任意数据库 key、图片 AES key、微信登录凭据、iLink `context_token`/bot token、inbound inbox raw items;这些是密钥或未处理消息,不是业务 API。
|
||||
- 通用 `settings:set`、任意 `dbRoot`、数据库 disconnect/reopen、cache 全清、Knowledge/OCR 索引 clear/reset;配置或 destructive blast radius 远高于 Agent automation 管理需求。
|
||||
- Agent Hub QR/login/verify-code/reconnect/disconnect/connector lifecycle。它会影响进程状态/账户登录,也会暴露登录材料。
|
||||
- `WechatInboundInbox` pending/clear/complete/recordFailure 等队列控制。它是 at-least-once 消息交付内部机制,外部 ack 会破坏不丢消息语义。
|
||||
- Agent Hub 完整对话正文默认不暴露。若明确产品需要,独立设计用户授权、最小时间窗口、分页、清理和脱敏;现有 Conversation Store 为本机完整收发留档。
|
||||
- 任意本地 file path、path traversal 类导出/报告资产操作;不要把 IPC 的 file chooser、reveal、delete path 形状直接变 HTTP 参数。
|
||||
- 单图 AI insight/任意 Vision analyze、批量 TTS synthesis、AI Provider 配置/测试和 app update download/install;它们具有费用、敏感图片/文本上传或应用安装影响。
|
||||
- 清除退群、Automation、发送/Action、报告历史或批量发送接口作为 P0。清理类不是“管理配置”的必要前提,应使用 R3 独立权限及本地可恢复流程。
|
||||
- 通用“任意 Automation DAG/任意脚本/任意 purpose”的创建。当前实现只有明确的三类规则和固定动作,不是 workflow engine;扩展功能应有显式 ruleType/schema,而不是暴露内存对象。
|
||||
|
||||
## 13. Open Questions
|
||||
|
||||
以下属于产品边界选择,无法只从代码决定:
|
||||
|
||||
1. 新 Agent API 是否允许同时操作个人微信和 Agent Hub/iLink,还是第一版限定一个 transport?现有 capability 与 send endpoint 分属两套连接状态。
|
||||
2. Agent 是否默认只拿只读 scope;配置和 R2 send 是否要求独立 token/用户确认?当前只有单一 Bearer Token。
|
||||
3. 多账号是否属于本阶段?当前 Query/GroupExit 绑定当前活动数据库账号,Agent Hub 有自己的 connector accountId;没有统一 account-scoped API model。
|
||||
4. Agent Hub 完整对话是否要作为 API capability?它含完整消息正文,和从微信数据库按 query 搜历史是不同隐私边界。
|
||||
|
||||
真实微信 runtime 可用性、平台 hydration 和 transport 能力需在后续实现集成测试中验证;静态代码审计无法替代真实数据库/微信连接的运行时验证。
|
||||
@@ -33,17 +33,24 @@ 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/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` |
|
||||
| 代码区域 | 需要同步检查的文档 |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
|
||||
| `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)是两条不同的路径**:前者写入本地索引、能被搜索,且不联网;后者只在日报和设置里的模型检测中使用。改其中一条时不要把另一条的隐私口径带过去。
|
||||
|
||||
## 文档检查
|
||||
|
||||
@@ -51,7 +58,10 @@ pnpm test:e2e:build
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
|
||||
# 过时版本号、旧品牌名、旧结构叙述、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'
|
||||
```
|
||||
|
||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
|
||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。发版前额外确认 `README.md` 里的版本号与 `package.json` 的 `version` 一致。
|
||||
|
||||
@@ -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,31 +27,39 @@ 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` |
|
||||
| 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` | 已连接机器人发送测试 |
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ------ | ------------------------------------------------------- | ------------------------------------------------- |
|
||||
| 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` 尤其需要用户明确确认。
|
||||
|
||||
## 定时日报管理
|
||||
|
||||
@@ -78,7 +86,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
}
|
||||
```
|
||||
|
||||
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
|
||||
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态不是 `ready` 时(`unsupported`、`unconfigured`、`needs_binding`、`initializing` 或 `error`),直接说明需要先在 TraceMemo 的“设置 → 发送能力”里完成个人微信绑定和能力检测,不要继续创建任务。
|
||||
|
||||
### 查看、修改和执行
|
||||
|
||||
@@ -102,6 +110,20 @@ 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
@@ -1,674 +0,0 @@
|
||||
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
@@ -1,28 +0,0 @@
|
||||
# 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`。
|
||||
@@ -33,7 +33,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
|
||||
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
|
||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。「图片文字索引」不在此列——它在本机识别图片里的文字,不需要配置 AI。
|
||||
|
||||
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
||||
|
||||
@@ -134,6 +134,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
|
||||
- [生成群聊日报或总结](./report.md)
|
||||
- [转写微信语音](./voice.md)
|
||||
- [导出聊天档案](./export.md)
|
||||
- [让日报、退群通知按规则自动运行](../README.md#日报与自动化)
|
||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||
|
||||
@@ -159,14 +160,17 @@ 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。
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
|
||||
### 状态怎么读
|
||||
|
||||
| 状态 | 含义 |
|
||||
| ---- | ---- |
|
||||
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
|
||||
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
|
||||
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
|
||||
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
|
||||
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
|
||||
| 状态 | 含义 |
|
||||
| ------------------- | ------------------------------------------------ |
|
||||
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
|
||||
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
|
||||
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
|
||||
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
|
||||
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
|
||||
|
||||
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
|
||||
|
||||
@@ -38,6 +38,21 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
|
||||
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
|
||||
|
||||
## 图片文字索引(另一份索引)
|
||||
|
||||
本地索引其实有两份,彼此独立:
|
||||
|
||||
- **聊天记录索引**(也就是上面说的 Knowledge):索引文字消息,用于跨会话、跨时间查找;
|
||||
- **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图等),把识别结果也变成可搜索的文字。
|
||||
|
||||
“独立”的意思是:聊天记录索引建好了,并不代表图片里的文字就搜得到。建立图片文字索引后,可以在“问问微信”里直接搜截图或公告图里写过的词。
|
||||
|
||||
图片文字索引只在本机识别,原始图片不会因为本地识别而上传。它**不等于“图片理解”**:识别文字不联网、不需要 AI 服务;而让模型看图并回答属于图片理解,需要配置 AI 服务,走的是另一条路径。
|
||||
|
||||
识别失败的图片可以单独重试,也有“只重建搜索索引、不重新识别图片”的修复入口——修索引不需要重跑几万张图。
|
||||
|
||||
两个索引都可以在“设置 → 本地索引”里集中查看状态、建立、同步和清理。
|
||||
|
||||
## 账号隔离
|
||||
|
||||
每个微信账号使用独立的本地索引。切换账号时,应用不会把一个账号的索引混入另一个账号的搜索结果。
|
||||
@@ -58,4 +73,3 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
## 产品术语(可选)
|
||||
|
||||
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
||||
- 读取和解析微信数据库;
|
||||
- 聊天档案浏览和普通关键词搜索;
|
||||
- 本地 Knowledge 索引及其账号隔离;
|
||||
- 图片文字索引:识别图片中的文字在本机完成,原始图片不会因为本地识别而上传;
|
||||
- 离线语音转写;
|
||||
- 导出文件生成和本地日报历史。
|
||||
|
||||
@@ -16,7 +17,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
||||
|
||||
## 什么时候会请求外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
|
||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。图片文字索引、离线语音转写、档案浏览和普通搜索不会触发这一步。当前设置页给出的边界是:
|
||||
|
||||
- 当前用户问题;
|
||||
- 受控检索所需的有限上下文;
|
||||
@@ -28,7 +29,14 @@ Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的
|
||||
|
||||
## 语音和媒体
|
||||
|
||||
离线语音转写在本机进行。图片理解属于 AI 功能:只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。无法读取的媒体不会被自动“猜出来”。
|
||||
离线语音转写在本机进行。
|
||||
|
||||
图片有两条完全不同的路径,不要混为一谈:
|
||||
|
||||
- **图片文字索引**:在本机识别图片里的文字,产出的是本地索引数据;原始图片不会因为这一步被上传,也不需要配置 AI 服务。
|
||||
- **图片理解**:属于 AI 功能。只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。
|
||||
|
||||
无法读取的媒体不会被自动“猜出来”。
|
||||
|
||||
## Local HTTP API
|
||||
|
||||
|
||||
@@ -33,9 +33,11 @@
|
||||
|
||||
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
||||
|
||||
## 定时日报
|
||||
## 定时日报(在自动化里)
|
||||
|
||||
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
|
||||
定时日报现在是「自动化」里的一种规则,不再单独占一个页面:打开一级导航的「自动化」,新建或编辑一条「定时日报」规则,选择群聊、执行时间、日报范围、消息类型和发送目标。日报页顶部的指引条也会直接跳到自动化。
|
||||
|
||||
TraceMemo 会按计划执行:
|
||||
|
||||
```text
|
||||
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
|
||||
@@ -45,6 +47,8 @@
|
||||
|
||||
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
|
||||
|
||||
发送目标当前支持**当前群聊、文件传输助手、自己、指定好友**——还不是任意群发。
|
||||
|
||||
## 让报告更可靠
|
||||
|
||||
- 先选正确的群和时间范围;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
@@ -34,7 +34,7 @@ extraResources:
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- '!connectors/wechat-personal/**'
|
||||
- '!runtime/darwin-arm64/**'
|
||||
- from: docs/skill/tracememo-reader
|
||||
to: skill/tracememo-reader
|
||||
filter:
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tracememo",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.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-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||
"prepare:wechat-native": "node scripts/prepare-wechat-native-runtime.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
||||
@@ -61,6 +61,7 @@
|
||||
"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",
|
||||
@@ -74,8 +75,10 @@
|
||||
"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",
|
||||
@@ -107,7 +110,9 @@
|
||||
"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",
|
||||
@@ -169,4 +174,4 @@
|
||||
"ffmpeg-static"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+38
-31
@@ -11,8 +11,10 @@ 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
|
||||
@@ -72,7 +74,9 @@ 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
|
||||
@@ -87,8 +91,10 @@ 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
|
||||
@@ -120,7 +126,9 @@ 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
|
||||
@@ -1585,7 +1593,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-freebsd-arm64/3.1.0:
|
||||
resolution: {integrity: sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==}
|
||||
@@ -1689,6 +1696,36 @@ 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'}
|
||||
@@ -1699,34 +1736,6 @@ 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'}
|
||||
@@ -7589,7 +7598,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/sherpa-onnx-linux-arm64/1.13.4:
|
||||
resolution: {integrity: sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw==}
|
||||
@@ -7628,7 +7636,6 @@ 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: 157 KiB After Width: | Height: | Size: 155 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 277 B |
Binary file not shown.
|
After Width: | Height: | Size: 702 B |
Binary file not shown.
+198
-18
@@ -125,13 +125,62 @@ 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) {
|
||||
execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' })
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function isMacosCodeValid(targetPath, run = runCodesign) {
|
||||
@@ -174,8 +223,89 @@ 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])
|
||||
@@ -325,9 +455,70 @@ 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)
|
||||
@@ -340,6 +531,7 @@ 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)
|
||||
@@ -352,23 +544,6 @@ 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
|
||||
@@ -378,6 +553,7 @@ exports.validateFfmpegRuntime = validateFfmpegRuntime
|
||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||
exports.validateSherpaRuntime = validateSherpaRuntime
|
||||
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
|
||||
exports.validateKoffiRuntime = validateKoffiRuntime
|
||||
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
|
||||
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
|
||||
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
|
||||
@@ -386,3 +562,7 @@ exports.findMacosHelperPaths = findMacosHelperPaths
|
||||
exports.isMacosCodeValid = isMacosCodeValid
|
||||
exports.signMacosHelpers = signMacosHelpers
|
||||
exports.signMacosAppBundle = signMacosAppBundle
|
||||
exports.findNestedMacosCodePaths = findNestedMacosCodePaths
|
||||
exports.sendRuntimeLocations = sendRuntimeLocations
|
||||
exports.findSendRuntime = findSendRuntime
|
||||
exports.enforceSendRuntimeBoundary = enforceSendRuntimeBoundary
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
/*
|
||||
* 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] 运行时准备完成')
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/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
|
||||
}
|
||||
}
|
||||
+425
-68
@@ -1,8 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
isReady,
|
||||
listContacts,
|
||||
listContactsAsync,
|
||||
listMessages,
|
||||
getGroupSnapshot,
|
||||
listRecentChat,
|
||||
@@ -27,6 +29,11 @@ import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
|
||||
import { LocalQueryApiService } from './services/local-query-api-service'
|
||||
import { automationRuleStore, AutomationRulePersistenceError } from './services/automation-rule-store'
|
||||
import { automationExecutionLogService } from './services/automation-execution-log-service'
|
||||
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
||||
import { LocalAgentApiError, LocalAgentApiService } from './services/local-agent-api-service'
|
||||
import type { GroupStatsService } from './services/group-stats-service'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
@@ -44,6 +51,11 @@ interface RouteContext {
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE'
|
||||
type RouteHandler = ((ctx: RouteContext) => void | Promise<void>) & {
|
||||
allowedMethods: readonly HttpMethod[]
|
||||
}
|
||||
|
||||
export interface HttpServerOptions {
|
||||
tokenProvider?: () => string | null
|
||||
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
|
||||
@@ -53,21 +65,56 @@ export interface HttpServerOptions {
|
||||
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
|
||||
scheduledReportPlatform?: NodeJS.Platform
|
||||
queryApiService?: LocalQueryApiService
|
||||
agentApiService?: LocalAgentApiService
|
||||
groupStatsService?: Pick<GroupStatsService, 'getMemberStats'>
|
||||
appVersionProvider?: () => string
|
||||
}
|
||||
|
||||
let configuredQueryApiService: LocalQueryApiService | undefined
|
||||
let configuredGroupStatsService: Pick<GroupStatsService, 'getMemberStats'> | undefined
|
||||
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
|
||||
configuredQueryApiService = service
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
export function setLocalGroupStatsService(
|
||||
service: Pick<GroupStatsService, 'getMemberStats'> | undefined
|
||||
): void {
|
||||
configuredGroupStatsService = service
|
||||
}
|
||||
|
||||
const MAX_JSON_BODY_BYTES = 1024 * 1024
|
||||
|
||||
class RequestBodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super('Request body exceeds the maximum size')
|
||||
this.name = 'RequestBodyTooLargeError'
|
||||
}
|
||||
}
|
||||
|
||||
function withMethods(
|
||||
methods: readonly HttpMethod[],
|
||||
handler: (ctx: RouteContext) => void | Promise<void>
|
||||
): RouteHandler {
|
||||
return Object.assign(handler, { allowedMethods: methods })
|
||||
}
|
||||
|
||||
function sendMethodNotAllowed(res: ServerResponse, methods: readonly HttpMethod[]): void {
|
||||
res.setHeader('Allow', methods.join(', '))
|
||||
sendError(res, 405, `请求方法不受支持;允许的方法:${methods.join(', ')}`)
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
const body = JSON.stringify(payload, null, 2)
|
||||
const requestId = String(res.getHeader('X-Request-Id') || '')
|
||||
let responsePayload = payload
|
||||
if (status >= 400 && payload && typeof payload === 'object' && !('requestId' in payload)) {
|
||||
responsePayload = { ...(payload as Record<string, unknown>), requestId }
|
||||
}
|
||||
const body = JSON.stringify(responsePayload, null, 2)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Cache-Control': 'no-store'
|
||||
'Cache-Control': 'no-store',
|
||||
...(requestId ? { 'X-Request-Id': requestId } : {})
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
@@ -90,8 +137,8 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!isAllowedCorsOrigin(origin)) return false
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, PATCH, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -111,6 +158,20 @@ function sendUnauthorized(res: ServerResponse): void {
|
||||
})
|
||||
}
|
||||
|
||||
function sendAgentHttpError(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
): void {
|
||||
const requestId = String(res.getHeader('X-Request-Id') || '')
|
||||
sendJson(res, status, {
|
||||
error: { code, message, ...(details !== undefined ? { details } : {}) },
|
||||
requestId
|
||||
})
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
||||
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||
}
|
||||
@@ -120,7 +181,8 @@ function sendBinary(res: ServerResponse, status: number, result: HttpImageResult
|
||||
'Content-Type': result.mimeType,
|
||||
'Content-Length': result.buffer.length,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Request-Id': String(res.getHeader('X-Request-Id') || '')
|
||||
})
|
||||
res.end(result.buffer)
|
||||
}
|
||||
@@ -135,10 +197,26 @@ function sanitizeChatlogMessage(message: Record<string, unknown>): Record<string
|
||||
return { ...message, contentData: safeContentData }
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
function readBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const contentLength = Number(req.headers['content-length'])
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
req.pause()
|
||||
reject(new RequestBodyTooLargeError())
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
let size = 0
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > maxBytes) {
|
||||
chunks.length = 0
|
||||
req.pause()
|
||||
reject(new RequestBodyTooLargeError())
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
@@ -207,18 +285,26 @@ function parseNumeric(value: string | null, fallback: number): number {
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function getApplicationVersion(): string {
|
||||
try {
|
||||
return typeof app.getVersion === 'function' ? app.getVersion() : 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const routes: Record<string, RouteHandler> = {
|
||||
'/api/v1/health': ({ res }) => {
|
||||
'/api/v1/health': withMethods(['GET'], ({ res }) => {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
ready: isReady(),
|
||||
service: 'TraceMemo Reader',
|
||||
version: '1.0.0',
|
||||
version: getApplicationVersion(),
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/current_time': ({ res }) => {
|
||||
'/api/v1/current_time': withMethods(['GET'], ({ res }) => {
|
||||
const now = new Date()
|
||||
sendJson(res, 200, {
|
||||
time: now.toISOString(),
|
||||
@@ -228,23 +314,28 @@ const routes: Record<string, RouteHandler> = {
|
||||
now.getDate()
|
||||
).padStart(2, '0')}`
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/contact': ({ res, url }) => {
|
||||
'/api/v1/contact': withMethods(['GET'], async ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const filter = url.searchParams.get('filter') || undefined
|
||||
const type = url.searchParams.get('type') || undefined
|
||||
let contacts = listContacts(filter)
|
||||
// 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)
|
||||
if (type === 'user' || type === 'group') {
|
||||
contacts = contacts.filter((c) => c.type === type)
|
||||
}
|
||||
sendJson(res, 200, { count: contacts.length, contacts })
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/chatroom': ({ res, url }) => {
|
||||
'/api/v1/chatroom': withMethods(['GET'], async ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
let groups = listContacts().filter((c) => c.type === 'group')
|
||||
// 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')
|
||||
if (keyword) {
|
||||
const lower = keyword.toLowerCase()
|
||||
groups = groups.filter(
|
||||
@@ -254,16 +345,16 @@ const routes: Record<string, RouteHandler> = {
|
||||
)
|
||||
}
|
||||
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/recent_chat': ({ res, url }) => {
|
||||
'/api/v1/recent_chat': withMethods(['GET'], ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const limit = parseNumeric(url.searchParams.get('limit'), 50)
|
||||
const items = listRecentChat(limit)
|
||||
sendJson(res, 200, { count: items.length, items })
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/chatlog': ({ res, url }) => {
|
||||
'/api/v1/chatlog': withMethods(['GET'], ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const talker = url.searchParams.get('talker')
|
||||
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
||||
@@ -301,27 +392,27 @@ const routes: Record<string, RouteHandler> = {
|
||||
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
|
||||
)
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/group_snapshot': ({ res, url }) => {
|
||||
'/api/v1/group_snapshot': withMethods(['GET'], ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const md5 = url.searchParams.get('md5')
|
||||
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
||||
const snapshot = getGroupSnapshot(md5)
|
||||
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
|
||||
sendJson(res, 200, snapshot)
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/resolve': ({ res, url }) => {
|
||||
'/api/v1/resolve': withMethods(['GET'], ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const q = url.searchParams.get('q')
|
||||
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
||||
const contact = resolveMd5(q)
|
||||
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
|
||||
sendJson(res, 200, contact)
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/report': async ({ req, res, body }) => {
|
||||
'/api/v1/report': withMethods(['POST'], async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
@@ -348,9 +439,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
}
|
||||
const result = await exportGroupReport(request)
|
||||
sendJson(res, result.success ? 200 : 500, result)
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/agent/group-report': async ({ req, res, body }) => {
|
||||
'/api/v1/agent/group-report': withMethods(['POST'], async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
|
||||
@@ -364,9 +455,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
range: request.range
|
||||
})
|
||||
sendJson(res, result.success ? 200 : 400, result)
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/agent/status': ({ res }) => {
|
||||
'/api/v1/agent/status': withMethods(['GET'], ({ res }) => {
|
||||
const status = agentHubService.getStatus()
|
||||
sendJson(res, 200, {
|
||||
ok: status.hub === 'online' && status.connector === 'online',
|
||||
@@ -376,9 +467,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
databaseReady: status.databaseReady,
|
||||
accountId: status.accountId
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
'/api/v1/agent/send': async ({ req, res, body }) => {
|
||||
'/api/v1/agent/send': withMethods(['POST'], async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
let request: { to?: string; text?: string; media_url?: string }
|
||||
try {
|
||||
@@ -392,7 +483,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
mediaUrl: request.media_url
|
||||
})
|
||||
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
|
||||
@@ -442,18 +533,18 @@ function createScheduledReportRoute(
|
||||
api: ScheduledReportApiService
|
||||
): RouteHandler | undefined {
|
||||
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
|
||||
return async ({ req, res }) => {
|
||||
return withMethods(['GET'], async ({ req, res }) => {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
try {
|
||||
sendJson(res, 200, { capability: await api.getCapability() })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === SCHEDULED_REPORTS_ROUTE) {
|
||||
return async ({ req, res, body }) => {
|
||||
return withMethods(['GET', 'POST'], async ({ req, res, body }) => {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const tasks = await api.list()
|
||||
@@ -469,7 +560,7 @@ function createScheduledReportRoute(
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
|
||||
@@ -482,7 +573,7 @@ function createScheduledReportRoute(
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return async ({ req, res }) => {
|
||||
return withMethods(['POST'], async ({ req, res }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
try {
|
||||
const execution = await api.retrySend(executionId)
|
||||
@@ -490,7 +581,7 @@ function createScheduledReportRoute(
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
||||
@@ -504,8 +595,15 @@ function createScheduledReportRoute(
|
||||
return undefined
|
||||
}
|
||||
const action = segments[1]
|
||||
if (action && !['enable', 'disable', 'run', 'executions'].includes(action)) return undefined
|
||||
|
||||
return async ({ req, res, body }) => {
|
||||
const methods: HttpMethod[] = !action
|
||||
? ['GET', 'PATCH', 'DELETE']
|
||||
: action === 'executions'
|
||||
? ['GET']
|
||||
: ['POST']
|
||||
|
||||
return withMethods(methods, async ({ req, res, body }) => {
|
||||
try {
|
||||
if (!action && req.method === 'GET') {
|
||||
sendJson(res, 200, { task: await api.get(taskId) })
|
||||
@@ -545,7 +643,7 @@ function createScheduledReportRoute(
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
||||
@@ -560,42 +658,46 @@ function queryStatusCode(status: string): number {
|
||||
return 400
|
||||
}
|
||||
|
||||
function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined {
|
||||
return async ({ req, res, body }) => {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
||||
if (pathname === '/api/v1/query/capabilities') {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
function createQueryRoute(pathname: string, api: LocalQueryApiService): RouteHandler | undefined {
|
||||
if (pathname === '/api/v1/query/capabilities') {
|
||||
return withMethods(['GET'], ({ res }) => {
|
||||
return sendJson(res, 200, api.capabilities())
|
||||
}
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
let payload: any
|
||||
})
|
||||
}
|
||||
type QueryOperationResult =
|
||||
| Awaited<ReturnType<LocalQueryApiService['messages']>>
|
||||
| Awaited<ReturnType<LocalQueryApiService['search']>>
|
||||
| Awaited<ReturnType<LocalQueryApiService['context']>>
|
||||
| Awaited<ReturnType<LocalQueryApiService['overview']>>
|
||||
const operations: Record<string, (payload: unknown) => Promise<QueryOperationResult>> = {
|
||||
'/api/v1/query/messages': (payload) =>
|
||||
api.messages(payload as Parameters<LocalQueryApiService['messages']>[0]),
|
||||
'/api/v1/query/search': (payload) =>
|
||||
api.search(payload as Parameters<LocalQueryApiService['search']>[0]),
|
||||
'/api/v1/query/message-context': (payload) =>
|
||||
api.context(payload as Parameters<LocalQueryApiService['context']>[0]),
|
||||
'/api/v1/query/conversation-overview': (payload) =>
|
||||
api.overview(payload as Parameters<LocalQueryApiService['overview']>[0])
|
||||
}
|
||||
const operation = operations[pathname]
|
||||
if (!operation) return undefined
|
||||
return withMethods(['POST'], async ({ res, body }) => {
|
||||
let payload: unknown
|
||||
try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') }
|
||||
if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request')
|
||||
try {
|
||||
const result = pathname === '/api/v1/query/messages'
|
||||
? await api.messages(payload)
|
||||
: pathname === '/api/v1/query/search'
|
||||
? await api.search(payload)
|
||||
: pathname === '/api/v1/query/message-context'
|
||||
? await api.context(payload)
|
||||
: pathname === '/api/v1/query/conversation-overview'
|
||||
? await api.overview(payload)
|
||||
: undefined
|
||||
if (!result) return sendError(res, 404, `端点不存在: ${pathname}`)
|
||||
const result = await operation(payload)
|
||||
return sendJson(res, queryStatusCode(result.status), result)
|
||||
} catch (error) {
|
||||
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createMediaRoute(
|
||||
mediaProvider: (messageId: string) => Promise<HttpImageResult>
|
||||
): RouteHandler {
|
||||
return async ({ req, res, url }) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
return sendError(res, 405, '需要 GET 请求')
|
||||
}
|
||||
return withMethods(['GET', 'HEAD'], async ({ req, res, url }) => {
|
||||
const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length)
|
||||
let messageId: string
|
||||
try {
|
||||
@@ -634,7 +736,218 @@ function createMediaRoute(
|
||||
safeError('[HttpServer] media request failed:', error)
|
||||
return sendError(res, 500, '图片读取失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createAgentApiService(options: HttpServerOptions): LocalAgentApiService {
|
||||
const groupStats = options.groupStatsService || configuredGroupStatsService
|
||||
return options.agentApiService || new LocalAgentApiService({
|
||||
automationRuleStore,
|
||||
automationExecutionLogService,
|
||||
listContacts: listContactsAsync,
|
||||
isDatabaseReady: isReady,
|
||||
getVersion: options.appVersionProvider || getApplicationVersion,
|
||||
getPersonalWechatCapability: () =>
|
||||
personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
||||
getAgentHubStatus: () => agentHubService.getStatus(),
|
||||
getGroupExitMonitorState: () => {
|
||||
const state = groupExitMonitorService.getState()
|
||||
return { ...state, monitoredRoomIds: state.monitoredRoomIds || [] }
|
||||
},
|
||||
configureGroupExitMonitor: (configuration) =>
|
||||
groupExitMonitorService.configure(configuration),
|
||||
setGroupExitMonitorRoomIds: (roomIds) => groupExitMonitorService.setMonitoredRoomIds(roomIds),
|
||||
setGroupExitMonitorEnabled: (enabled) => groupExitMonitorService.setEnabled(enabled),
|
||||
listGroupExitMonitorEvents: (query) => groupExitMonitorService.listEvents(query),
|
||||
...(groupStats ? { getGroupMemberStats: (query) => groupStats.getMemberStats(query) } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function sendAgentApiError(res: ServerResponse, error: unknown): void {
|
||||
const requestId = String(res.getHeader('X-Request-Id') || '')
|
||||
if (error instanceof LocalAgentApiError) {
|
||||
sendAgentHttpError(res, error.status, error.code, error.message, error.details)
|
||||
return
|
||||
}
|
||||
if (error instanceof AutomationRulePersistenceError) {
|
||||
sendAgentHttpError(res, 500, 'PERSISTENCE_FAILED', '自动化规则未能保存到本地')
|
||||
return
|
||||
}
|
||||
safeError(`[HttpServer requestId=${requestId}] Agent API request failed:`, error)
|
||||
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
|
||||
}
|
||||
|
||||
async function handleAgentApi<T>(
|
||||
res: ServerResponse,
|
||||
operation: () => Promise<T> | T,
|
||||
respond: (value: T) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
respond(await operation())
|
||||
} catch (error) {
|
||||
sendAgentApiError(res, error)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAgentJson(body: unknown): unknown {
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体不能为空')
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body)
|
||||
} catch {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体 JSON 格式无效')
|
||||
}
|
||||
}
|
||||
|
||||
function createAgentApiRoute(
|
||||
pathname: string,
|
||||
api: LocalAgentApiService
|
||||
): RouteHandler | undefined {
|
||||
if (pathname === '/api/v1/capabilities') {
|
||||
return withMethods(['GET'], ({ res }) =>
|
||||
void handleAgentApi(res, () => api.getCapabilities(), (capabilities) =>
|
||||
sendJson(res, 200, capabilities)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const groupExitMonitorPath = '/api/v1/monitors/group-exits'
|
||||
if (pathname === groupExitMonitorPath) {
|
||||
return withMethods(['GET', 'PATCH'], async ({ req, res, body }) => {
|
||||
if (req.method === 'GET') {
|
||||
await handleAgentApi(res, () => api.getGroupExitMonitorState(), (state) =>
|
||||
sendJson(res, 200, state)
|
||||
)
|
||||
return
|
||||
}
|
||||
await handleAgentApi(res, () => api.updateGroupExitMonitor(parseAgentJson(body)), (state) =>
|
||||
sendJson(res, 200, state)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === `${groupExitMonitorPath}/events`) {
|
||||
return withMethods(['GET'], ({ res, url }) =>
|
||||
void handleAgentApi(res, () => api.listGroupExitMonitorEvents(url.searchParams), (result) =>
|
||||
sendJson(res, 200, result)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const groupStatsPrefix = '/api/v1/groups/'
|
||||
if (pathname.startsWith(groupStatsPrefix)) {
|
||||
const segments = pathname.slice(groupStatsPrefix.length).split('/')
|
||||
if (segments.length === 2 && segments[1] === 'member-stats' && segments[0]) {
|
||||
let conversationId: string
|
||||
try {
|
||||
conversationId = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return withMethods(['GET'], ({ res, url }) =>
|
||||
void handleAgentApi(
|
||||
res,
|
||||
() => api.getGroupMemberStats(conversationId, url.searchParams),
|
||||
(result) => sendJson(res, 200, result)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const automationCollection = '/api/v1/automations'
|
||||
if (pathname === automationCollection) {
|
||||
return withMethods(['GET', 'POST'], async ({ req, res, url, body }) => {
|
||||
if (req.method === 'GET') {
|
||||
await handleAgentApi(
|
||||
res,
|
||||
() => api.listAutomations({
|
||||
type: url.searchParams.get('type'),
|
||||
enabled: url.searchParams.get('enabled')
|
||||
}),
|
||||
(rules) => sendJson(res, 200, { count: rules.length, rules })
|
||||
)
|
||||
return
|
||||
}
|
||||
await handleAgentApi(res, () => api.createAutomation(parseAgentJson(body)), (rule) =>
|
||||
sendJson(res, 201, { created: true, rule })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === `${automationCollection}/validate`) {
|
||||
return withMethods(['POST'], async ({ res, body }) => {
|
||||
await handleAgentApi(res, () => api.validateAutomation(parseAgentJson(body)), (result) =>
|
||||
sendJson(res, 200, result)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === `${automationCollection}/executions`) {
|
||||
return withMethods(['GET'], ({ res, url }) =>
|
||||
void handleAgentApi(res, () => api.listExecutions(url.searchParams), (result) =>
|
||||
sendJson(res, 200, result)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const prefix = `${automationCollection}/`
|
||||
if (!pathname.startsWith(prefix)) return undefined
|
||||
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
||||
if (segments.length < 1 || segments.length > 2) return undefined
|
||||
let id: string
|
||||
try {
|
||||
id = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (!id || id.includes('/') || id.includes('\\')) return undefined
|
||||
const action = segments[1]
|
||||
if (action && action !== 'enable' && action !== 'disable') return undefined
|
||||
|
||||
if (action) {
|
||||
return withMethods(['POST'], ({ res }) =>
|
||||
void handleAgentApi(res, () => api.setAutomationEnabled(id, action === 'enable'), (rule) =>
|
||||
sendJson(res, 200, { updated: true, rule })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return withMethods(['GET', 'PATCH', 'DELETE'], async ({ req, res, body }) => {
|
||||
if (req.method === 'GET') {
|
||||
await handleAgentApi(res, () => api.getAutomation(id), (rule) =>
|
||||
sendJson(res, 200, { rule })
|
||||
)
|
||||
return
|
||||
}
|
||||
if (req.method === 'PATCH') {
|
||||
await handleAgentApi(
|
||||
res,
|
||||
() => api.updateAutomation(id, parseAgentJson(body)),
|
||||
(rule) => sendJson(res, 200, { updated: true, rule })
|
||||
)
|
||||
return
|
||||
}
|
||||
await handleAgentApi(res, () => api.deleteAutomation(id), (result) =>
|
||||
sendJson(res, 200, { deleted: true, ...result })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function requestIdFor(req: IncomingMessage): string {
|
||||
const incoming = req.headers['x-request-id']
|
||||
return typeof incoming === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(incoming)
|
||||
? incoming
|
||||
: crypto.randomUUID()
|
||||
}
|
||||
|
||||
function isAgentApiPath(pathname: string): boolean {
|
||||
return pathname === '/api/v1/capabilities' ||
|
||||
pathname === '/api/v1/monitors/group-exits' ||
|
||||
pathname.startsWith('/api/v1/monitors/group-exits/') ||
|
||||
pathname.startsWith('/api/v1/groups/') ||
|
||||
pathname === '/api/v1/automations' ||
|
||||
pathname.startsWith('/api/v1/automations/')
|
||||
}
|
||||
|
||||
export function startHttpServer(
|
||||
@@ -646,11 +959,19 @@ export function startHttpServer(
|
||||
const mediaProvider = options.mediaProvider || readImageMedia
|
||||
const scheduledReportApi = createScheduledReportApi(options)
|
||||
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
|
||||
const agentApi = createAgentApiService(options)
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
const requestId = requestIdFor(req)
|
||||
res.setHeader('X-Request-Id', requestId)
|
||||
let agentApiRequest = false
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||
agentApiRequest = isAgentApiPath(url.pathname)
|
||||
if (!applyCorsHeaders(req, res)) {
|
||||
if (agentApiRequest) {
|
||||
return sendAgentHttpError(res, 403, 'FORBIDDEN', 'Origin 不允许访问本地 API')
|
||||
}
|
||||
return sendError(res, 403, 'Origin 不允许访问本地 API')
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -659,15 +980,37 @@ export function startHttpServer(
|
||||
}
|
||||
const handler =
|
||||
routes[url.pathname] ||
|
||||
createAgentApiRoute(url.pathname, agentApi) ||
|
||||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
|
||||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
|
||||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX)
|
||||
? createQueryRoute(url.pathname, queryApi)
|
||||
: undefined) ||
|
||||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
|
||||
? createMediaRoute(mediaProvider)
|
||||
: undefined)
|
||||
if (!handler) {
|
||||
if (agentApiRequest) {
|
||||
return sendAgentHttpError(res, 404, 'NOT_FOUND', `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
if (!handler.allowedMethods.includes(req.method as HttpMethod)) {
|
||||
if (agentApiRequest) {
|
||||
res.setHeader('Allow', handler.allowedMethods.join(', '))
|
||||
return sendAgentHttpError(
|
||||
res,
|
||||
405,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
`请求方法不受支持;允许的方法:${handler.allowedMethods.join(', ')}`,
|
||||
{ allowedMethods: handler.allowedMethods }
|
||||
)
|
||||
}
|
||||
return sendMethodNotAllowed(res, handler.allowedMethods)
|
||||
}
|
||||
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
|
||||
if (agentApiRequest) {
|
||||
return sendAgentHttpError(res, 401, 'UNAUTHORIZED', 'Valid API token required')
|
||||
}
|
||||
return sendUnauthorized(res)
|
||||
}
|
||||
let body: string | undefined
|
||||
@@ -677,8 +1020,22 @@ export function startHttpServer(
|
||||
const ctx: RouteContext = { req, res, url, body }
|
||||
await handler(ctx)
|
||||
} catch (error) {
|
||||
safeError('[HttpServer] 请求处理失败:', error)
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
res.setHeader('Connection', 'close')
|
||||
res.shouldKeepAlive = false
|
||||
if (agentApiRequest) {
|
||||
sendAgentHttpError(res, 413, 'PAYLOAD_TOO_LARGE', '请求体不能超过 1 MiB')
|
||||
return
|
||||
}
|
||||
sendError(res, 413, '请求体不能超过 1 MiB')
|
||||
return
|
||||
}
|
||||
safeError(`[HttpServer requestId=${requestId}] 请求处理失败:`, error)
|
||||
if (!res.headersSent) {
|
||||
if (agentApiRequest) {
|
||||
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
|
||||
return
|
||||
}
|
||||
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
+436
-122
@@ -14,13 +14,17 @@ import {
|
||||
Menu,
|
||||
Tray,
|
||||
dialog,
|
||||
protocol
|
||||
protocol,
|
||||
screen
|
||||
} 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'
|
||||
@@ -72,7 +76,7 @@ import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../
|
||||
import { KeyServiceMac } from './key-service-mac'
|
||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||
import * as chat from './services/chat-service'
|
||||
import { apiServer, setLocalQueryApiService } from './http-server'
|
||||
import { apiServer, setLocalGroupStatsService, setLocalQueryApiService } from './http-server'
|
||||
import { skillResourceService } from './services/skill-resource-service'
|
||||
import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service'
|
||||
import { isWechatRunning } from './services/wechat-process-status'
|
||||
@@ -112,22 +116,28 @@ 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 { wechatActionGateway } from './services/wechat-action-gateway'
|
||||
import { toPersonalWechatSendResult, 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
|
||||
PersonalWechatSendResult,
|
||||
PersonalWechatSenderStatus
|
||||
} 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 {
|
||||
@@ -196,6 +206,12 @@ 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.
|
||||
@@ -211,6 +227,7 @@ 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
|
||||
@@ -222,7 +239,6 @@ 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()
|
||||
@@ -357,6 +373,18 @@ 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([
|
||||
{
|
||||
@@ -492,9 +520,29 @@ 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: 1400,
|
||||
height: 800,
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
center: true,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
icon: appIconPath,
|
||||
@@ -754,6 +802,10 @@ 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(() =>
|
||||
@@ -830,11 +882,6 @@ 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 })
|
||||
@@ -890,8 +937,6 @@ 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
|
||||
@@ -997,20 +1042,120 @@ 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((type, json) => {
|
||||
const monitoring = await wcdb4Client.startMonitor((event) => {
|
||||
wcdb4Client.invalidateSessionCache()
|
||||
groupExitMonitorService.notifyDatabaseChanged(json)
|
||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||
// 正式 MessageListener:只做 coalesce + 有界回读 + dedup + 投递,不触发业务。
|
||||
// v2 事件带 sessionId ⇒ 回读不再依赖「最近活跃会话」。
|
||||
messageListener.handleNativeChange(event)
|
||||
groupExitMonitorService.notifyDatabaseChanged(event.raw)
|
||||
recallArchiveMonitor?.handleDatabaseChange(event.raw)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('wcdb-change', { type: event.type, json: event.raw })
|
||||
}
|
||||
}
|
||||
})
|
||||
automationListening = monitoring === true
|
||||
void groupExitMonitorService.start(monitoring)
|
||||
const recentSession = sessions[0]
|
||||
if (recentSession?.username) {
|
||||
@@ -1152,7 +1297,54 @@ app.whenReady().then(async () => {
|
||||
)
|
||||
// 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。
|
||||
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
|
||||
if (request.type !== 'voice' || String(request.fromId || '').trim()) {
|
||||
// 项目规则:**所有发送都必须经过 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()) {
|
||||
return personalWechatSendService.send(request)
|
||||
}
|
||||
let fromId = ''
|
||||
@@ -1469,31 +1661,153 @@ 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[], 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:setGroups', (_, roomIds: string[]) =>
|
||||
groupExitMonitorService.setMonitoredRoomIds(Array.isArray(roomIds) ? roomIds : [])
|
||||
)
|
||||
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',
|
||||
@@ -1713,44 +2027,58 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
|
||||
ipcMain.handle('scheduled-report:list', () => scheduledReportService.listTasks())
|
||||
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
|
||||
scheduledReportService.listExecutions(taskId)
|
||||
|
||||
/**
|
||||
* 定时日报(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:getNotificationSettings', () =>
|
||||
ipcMain.handle('automation:getScheduledReportNotificationSettings', () =>
|
||||
scheduledReportService.getNotificationSettings()
|
||||
)
|
||||
ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) =>
|
||||
ipcMain.handle('automation:getScheduledReportNotificationCapability', () =>
|
||||
scheduledReportService.checkNotificationCapability()
|
||||
)
|
||||
ipcMain.handle('automation:setScheduledReportNotificationEnabled', (_, enabled: boolean) =>
|
||||
scheduledReportService.setNotificationEnabled(Boolean(enabled))
|
||||
)
|
||||
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) => {
|
||||
ipcMain.handle('automation:testScheduledReportErrorNotification', (_, ruleId: 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(taskId)
|
||||
return scheduledReportService.testScheduledReportErrorNotification(ruleId)
|
||||
})
|
||||
|
||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||
@@ -2216,11 +2544,13 @@ app.whenReady().then(async () => {
|
||||
if (client) {
|
||||
voiceService = new VoiceService(client, client.getAccountRoot())
|
||||
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
||||
const monitoring = await client.startMonitor((type, json) => {
|
||||
const monitoring = await client.startMonitor((event) => {
|
||||
client.invalidateSessionCache()
|
||||
groupExitMonitorService.notifyDatabaseChanged(json)
|
||||
groupExitMonitorService.notifyDatabaseChanged(event.raw)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('wcdb-change', { type: event.type, json: event.raw })
|
||||
}
|
||||
}
|
||||
})
|
||||
void groupExitMonitorService.start(monitoring)
|
||||
@@ -2345,42 +2675,16 @@ app.whenReady().then(async () => {
|
||||
agentHubService.clearConversations()
|
||||
return { success: true }
|
||||
})
|
||||
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:getStatus', () => currentPersonalWechatSenderStatus())
|
||||
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
|
||||
personalWechatSendService.checkWindowsStatus(port)
|
||||
)
|
||||
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
|
||||
personalWechatVoiceEnvironmentService.check()
|
||||
)
|
||||
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:installPilk', () =>
|
||||
personalWechatVoiceEnvironmentService.installPilk()
|
||||
)
|
||||
ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => {
|
||||
try {
|
||||
await shell.openExternal('https://www.python.org/downloads/macos/')
|
||||
@@ -2397,23 +2701,20 @@ app.whenReady().then(async () => {
|
||||
return { success: false, error: '无法打开 FFmpeg 安装页面' }
|
||||
}
|
||||
})
|
||||
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: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: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) => {
|
||||
@@ -2441,7 +2742,7 @@ app.whenReady().then(async () => {
|
||||
action.sendResult && typeof action.sendResult === 'object'
|
||||
? (action.sendResult as PersonalWechatSendResult)
|
||||
: undefined
|
||||
const status = sendResult?.status || (await personalWechatSendService.getStatus())
|
||||
const status = sendResult?.status || (await currentPersonalWechatSenderStatus())
|
||||
return { action, status }
|
||||
}
|
||||
)
|
||||
@@ -2516,9 +2817,21 @@ app.on('before-quit', (event) => {
|
||||
chat.closeChatDbForQuit().catch(() => false),
|
||||
voiceRecognition?.dispose().catch(() => undefined),
|
||||
knowledgeSearchService?.dispose().catch(() => undefined),
|
||||
personalWechatSendService.terminate().catch((error) => {
|
||||
console.warn('[Shutdown] personal WeChat sender cleanup failed:', error)
|
||||
})
|
||||
/*
|
||||
* 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()
|
||||
])
|
||||
if (!nativeCallsDrained) {
|
||||
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
||||
@@ -2576,11 +2889,12 @@ function buildTrayMenu(): Menu {
|
||||
function setupTray(): void {
|
||||
if (tray) return
|
||||
try {
|
||||
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' })
|
||||
const trayImage = createTrayImage(
|
||||
process.platform,
|
||||
appIconPath,
|
||||
{ oneX: trayTemplateIconPath, twoX: trayTemplateRetinaIconPath },
|
||||
nativeImage
|
||||
)
|
||||
tray = new Tray(trayImage)
|
||||
tray.setToolTip('TraceMemo')
|
||||
// macOS may show a Tray context menu on a primary click when it is set
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
KnowledgeImageOcrState,
|
||||
KnowledgeAttachmentMetadata,
|
||||
KnowledgeEvidence,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeMessageKind,
|
||||
KnowledgePassProgress,
|
||||
KnowledgeRuntimeState,
|
||||
@@ -498,6 +499,37 @@ 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` 组成。
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -53,6 +55,13 @@ 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()
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeNormalizedMessage,
|
||||
KnowledgeQuery,
|
||||
@@ -317,6 +318,93 @@ 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,6 +6,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -19,6 +21,7 @@ type WorkerResult =
|
||||
| KnowledgeCapacityPreflight
|
||||
| KnowledgeSearchResult
|
||||
| KnowledgeRuntimeStatus
|
||||
| KnowledgeMemberStatsResult
|
||||
| { marks: Record<string, number> }
|
||||
| { removed: true }
|
||||
type PendingRequest = {
|
||||
@@ -76,6 +79,11 @@ export class KnowledgeWorkerHost {
|
||||
)
|
||||
}
|
||||
|
||||
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
|
||||
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
|
||||
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
|
||||
async cancelActiveIndex(): Promise<boolean> {
|
||||
const target = this.activeIndexRequestId
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
KnowledgeCapacityPreflightRequest,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeStatusRequest,
|
||||
@@ -187,6 +189,38 @@ 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') {
|
||||
@@ -226,6 +260,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
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()
|
||||
@@ -0,0 +1,153 @@
|
||||
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()
|
||||
@@ -0,0 +1,676 @@
|
||||
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
@@ -0,0 +1,266 @@
|
||||
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 }
|
||||
@@ -1036,6 +1036,20 @@ 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
|
||||
|
||||
@@ -2,19 +2,17 @@ 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
|
||||
@@ -23,9 +21,14 @@ type StoredState = {
|
||||
lastReadAt?: number
|
||||
monitorSelectionConfigured?: boolean
|
||||
monitoredRoomIds?: string[]
|
||||
notificationRoomIds?: string[]
|
||||
notificationTemplate?: unknown
|
||||
snapshots?: Partial<StoredGroupSnapshot>[]
|
||||
/*
|
||||
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
|
||||
*
|
||||
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
|
||||
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
|
||||
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
|
||||
*/
|
||||
}
|
||||
|
||||
type GroupSnapshotRecord = {
|
||||
@@ -57,19 +60,30 @@ 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 {
|
||||
actionGateway?: GroupExitActionGateway
|
||||
onGroupExit?: GroupExitEventHandler
|
||||
}
|
||||
|
||||
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
|
||||
Partial<
|
||||
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
|
||||
>
|
||||
|
||||
class GroupExitMonitorService {
|
||||
private readonly actionGateway: GroupExitActionGateway
|
||||
private onGroupExit: GroupExitEventHandler | undefined
|
||||
private active = false
|
||||
private enabled = true
|
||||
private nativeMonitorActive = false
|
||||
@@ -87,7 +101,6 @@ 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>()
|
||||
@@ -95,24 +108,34 @@ class GroupExitMonitorService {
|
||||
private eventSequence = 0
|
||||
private scopeGeneration = 0
|
||||
private legacyFallbackLogged = false
|
||||
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||
|
||||
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
||||
this.actionGateway = deps.actionGateway || wechatActionGateway
|
||||
this.onGroupExit = deps.onGroupExit
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
|
||||
*
|
||||
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
|
||||
* 要等数据库解锁后才存在,而本服务是模块级单例。
|
||||
*/
|
||||
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
|
||||
this.onGroupExit = handler
|
||||
}
|
||||
|
||||
getState(): GroupExitMonitorState {
|
||||
this.ensureLoaded()
|
||||
return {
|
||||
events: [...this.events],
|
||||
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
|
||||
events: this.events.slice(0, MAX_EVENTS),
|
||||
/** 永久保留的事件总数(`events` 只是最新的一批)。 */
|
||||
totalEventCount: this.events.length,
|
||||
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
|
||||
@@ -132,12 +155,11 @@ 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()
|
||||
}
|
||||
@@ -208,75 +230,116 @@ class GroupExitMonitorService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async setMonitoredRoomIds(
|
||||
roomIds: string[],
|
||||
notificationRoomIds: string[] = []
|
||||
): Promise<GroupExitMonitorState> {
|
||||
/**
|
||||
* 保存**监控范围**。
|
||||
*
|
||||
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
|
||||
* 本服务不再持有任何通知配置。
|
||||
*/
|
||||
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
|
||||
return this.configure({ monitoredRoomIds: roomIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically update the monitor scope and enabled state.
|
||||
*
|
||||
* Agent API PATCH validates the whole request before calling this method. Keeping the
|
||||
* two mutations in one service operation also means persistence and the renderer broadcast
|
||||
* observe one final configuration rather than an intermediate half-patched state.
|
||||
*/
|
||||
async configure(configuration: {
|
||||
monitoredRoomIds?: string[]
|
||||
enabled?: boolean
|
||||
}): Promise<GroupExitMonitorState> {
|
||||
this.ensureLoaded()
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
this.monitorSelectionConfigured = true
|
||||
const nextMonitoredRoomIds = normalizeRoomIds(roomIds)
|
||||
for (const roomId of this.snapshots.keys()) {
|
||||
if (!nextMonitoredRoomIds.has(roomId)) this.snapshots.delete(roomId)
|
||||
const hasScope = configuration.monitoredRoomIds !== undefined
|
||||
const hasEnabled = configuration.enabled !== undefined
|
||||
if (!hasScope && !hasEnabled) return this.getState()
|
||||
if (hasEnabled && !hasScope && this.enabled === configuration.enabled) return this.getState()
|
||||
|
||||
if (hasScope) {
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
this.monitorSelectionConfigured = true
|
||||
const nextMonitoredRoomIds = normalizeRoomIds(configuration.monitoredRoomIds || [])
|
||||
for (const roomId of this.snapshots.keys()) {
|
||||
if (!nextMonitoredRoomIds.has(roomId)) this.snapshots.delete(roomId)
|
||||
}
|
||||
for (const roomId of this.hydrationQueue) {
|
||||
if (!nextMonitoredRoomIds.has(roomId)) this.hydrationQueue.delete(roomId)
|
||||
}
|
||||
this.monitoredRoomIds = nextMonitoredRoomIds
|
||||
this.groupNamesRefreshPending = true
|
||||
this.lastCheckedAt = undefined
|
||||
}
|
||||
for (const roomId of this.hydrationQueue) {
|
||||
if (!nextMonitoredRoomIds.has(roomId)) this.hydrationQueue.delete(roomId)
|
||||
|
||||
if (hasEnabled && this.enabled !== configuration.enabled) {
|
||||
this.enabled = configuration.enabled === true
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
this.checkQueued = false
|
||||
this.hydrationQueue.clear()
|
||||
if (this.changeTimer) clearTimeout(this.changeTimer)
|
||||
this.changeTimer = null
|
||||
|
||||
if (this.enabled) {
|
||||
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
|
||||
this.snapshots.clear()
|
||||
this.lastCheckedAt = undefined
|
||||
this.groupNamesRefreshPending = true
|
||||
}
|
||||
}
|
||||
this.monitoredRoomIds = nextMonitoredRoomIds
|
||||
this.groupNamesRefreshPending = true
|
||||
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
|
||||
this.notificationRoomIds = new Set(
|
||||
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
|
||||
)
|
||||
this.lastCheckedAt = undefined
|
||||
|
||||
this.save()
|
||||
this.broadcast()
|
||||
// 建立基线放到后台,保存配置可以立即返回。
|
||||
if (this.enabled && this.active) void this.check()
|
||||
// 仅修改范围时保持原有的后台基线行为;涉及 enabled 的 PATCH 等待一次检查,
|
||||
// 让调用方拿到的是最终运行状态。
|
||||
if (this.enabled && this.active && chat.isReady()) {
|
||||
if (hasEnabled) await this.check()
|
||||
else void this.check()
|
||||
}
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
|
||||
this.ensureLoaded()
|
||||
if (this.enabled === enabled) return this.getState()
|
||||
|
||||
this.enabled = enabled
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
this.checkQueued = false
|
||||
this.hydrationQueue.clear()
|
||||
if (this.changeTimer) clearTimeout(this.changeTimer)
|
||||
this.changeTimer = null
|
||||
|
||||
if (enabled) {
|
||||
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
|
||||
this.snapshots.clear()
|
||||
this.lastCheckedAt = undefined
|
||||
this.groupNamesRefreshPending = true
|
||||
}
|
||||
this.save()
|
||||
this.broadcast()
|
||||
if (enabled && this.active && chat.isReady()) await this.check()
|
||||
return this.getState()
|
||||
return this.configure({ enabled })
|
||||
}
|
||||
|
||||
setNotificationTemplate(value: unknown): GroupExitMonitorState {
|
||||
/**
|
||||
* 按群 / 时间范围查退群事件(档案合并展示用)。
|
||||
*
|
||||
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
|
||||
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
|
||||
* 直接打在内存里的完整历史上(事件是永久保留的)。
|
||||
*
|
||||
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
|
||||
*/
|
||||
listEvents(
|
||||
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
|
||||
): GroupExitMonitorEvent[] {
|
||||
this.ensureLoaded()
|
||||
const result = validateGroupExitNotificationTemplate(value)
|
||||
if (!result.valid || !result.template) {
|
||||
throw new Error(result.error || '退群监测模板无效')
|
||||
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)
|
||||
}
|
||||
this.notificationTemplate = result.template
|
||||
this.save()
|
||||
this.broadcast()
|
||||
return this.getState()
|
||||
return result
|
||||
}
|
||||
|
||||
clearEvents(): GroupExitMonitorState {
|
||||
this.ensureLoaded()
|
||||
this.events = []
|
||||
this.actionGateway.clearMemberEvents?.()
|
||||
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
|
||||
this.rewriteEventsToDisk([])
|
||||
this.lastReadAt = Date.now()
|
||||
this.save()
|
||||
this.broadcast()
|
||||
@@ -399,7 +462,7 @@ class GroupExitMonitorService {
|
||||
groups: GroupMembershipRecord[],
|
||||
scopeGeneration: number
|
||||
): Promise<number> {
|
||||
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
|
||||
const exits: GroupExitMonitorEvent[] = []
|
||||
let changedGroups = 0
|
||||
for (const membership of groups) {
|
||||
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
|
||||
@@ -430,10 +493,9 @@ 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 && this.notificationRoomIds.has(next.roomId)) {
|
||||
notifications.push({ group: next, event })
|
||||
}
|
||||
if (event) exits.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,12 +510,11 @@ class GroupExitMonitorService {
|
||||
return changedGroups
|
||||
}
|
||||
this.lastCheckedAt = Date.now()
|
||||
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
|
||||
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
|
||||
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
|
||||
this.save()
|
||||
this.broadcast()
|
||||
if (notifications.length) {
|
||||
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
|
||||
}
|
||||
for (const event of exits) this.emitGroupExit(event)
|
||||
return changedGroups
|
||||
}
|
||||
|
||||
@@ -589,129 +650,145 @@ class GroupExitMonitorService {
|
||||
currentCount,
|
||||
delta: currentCount - previousCount,
|
||||
message,
|
||||
detectedAt,
|
||||
notificationStatus: 'not_requested'
|
||||
detectedAt
|
||||
}
|
||||
this.actionGateway.registerMemberEvent?.(event)
|
||||
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
|
||||
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
|
||||
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
|
||||
this.events = [event, ...this.events]
|
||||
this.appendEventsToDisk([event])
|
||||
console.log(
|
||||
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
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()
|
||||
/**
|
||||
* 把退群事件交给自动化 —— **不等待**。
|
||||
*
|
||||
* 三条约束:
|
||||
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
|
||||
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
|
||||
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
|
||||
*/
|
||||
private emitGroupExit(event: GroupExitMonitorEvent): void {
|
||||
const handler = this.onGroupExit
|
||||
if (!handler) return
|
||||
try {
|
||||
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') {
|
||||
void Promise.resolve(
|
||||
handler(toGroupMemberExitedEvent(event))
|
||||
).catch((error) => {
|
||||
console.warn(
|
||||
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
|
||||
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
event.notificationStatus = 'failed'
|
||||
event.notification = {
|
||||
status: 'failed',
|
||||
errorCode: 'UNKNOWN',
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
|
||||
console.warn(
|
||||
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
|
||||
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
|
||||
this.events = normalizeEvents(stored.events)
|
||||
this.actionGateway.registerMemberEvents?.(this.events)
|
||||
// 事件从 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.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 = []
|
||||
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
|
||||
// 不该被状态文件的问题连累,仍然读回来。
|
||||
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
|
||||
this.enabled = true
|
||||
this.lastReadAt = 0
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds.clear()
|
||||
this.notificationRoomIds.clear()
|
||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||
this.snapshots.clear()
|
||||
}
|
||||
}
|
||||
@@ -725,12 +802,11 @@ class GroupExitMonitorService {
|
||||
{
|
||||
accountRoot: this.accountRoot,
|
||||
enabled: this.enabled,
|
||||
events: this.events,
|
||||
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
|
||||
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
|
||||
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 }
|
||||
@@ -927,39 +1003,28 @@ function normalizeEvents(
|
||||
? Number(value.delta)
|
||||
: currentCount - previousCount,
|
||||
message: String(value.message || `${memberName}退出了${groupName}`),
|
||||
detectedAt,
|
||||
...(value.notificationStatus
|
||||
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
|
||||
: {}),
|
||||
...(value.notification && typeof value.notification === 'object'
|
||||
? { notification: normalizeNotification(value.notification) }
|
||||
: {})
|
||||
detectedAt
|
||||
})
|
||||
if (normalized.length >= MAX_EVENTS) break
|
||||
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
|
||||
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
|
||||
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
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) } : {})
|
||||
}
|
||||
/**
|
||||
* 事件在内存里恒定保持「**新在前**」。
|
||||
*
|
||||
* 这个不变量有三个依赖方:`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 isContactEvent(rawPayload: string): boolean {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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'
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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 }
|
||||
@@ -0,0 +1,967 @@
|
||||
import {
|
||||
BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
calculateNextRunAt,
|
||||
type AutomationExecution,
|
||||
type AutomationRule,
|
||||
type AutomationRuleDraft,
|
||||
type AutomationRuleType
|
||||
} from '../../shared/automation'
|
||||
import { resolveContact } from './contact-resolution-service'
|
||||
import { validateAutomationDraftShape } from '../../shared/agent-api/automation-validation'
|
||||
import type {
|
||||
AgentAutomationExecution,
|
||||
AgentAutomationValidationIssue,
|
||||
AgentAutomationValidationResult,
|
||||
ApplicationCapabilities
|
||||
} from '../../shared/agent-api/contracts'
|
||||
import type {
|
||||
AgentGroupExitMonitorEvent,
|
||||
AgentGroupExitMonitorState
|
||||
} from '../../shared/agent-api/group-exit-monitor'
|
||||
import type { AgentGroupMemberStats } from '../../shared/agent-api/group-stats'
|
||||
import type { GroupExitMonitorEvent } from '../../shared/group-exit-monitor'
|
||||
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../../shared/group-stats'
|
||||
import type { Contact } from '../../shared/types'
|
||||
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
|
||||
import type { AutomationExecutionLogService } from './automation-execution-log-service'
|
||||
import type { AutomationRuleStore } from './automation-rule-store'
|
||||
|
||||
type AutomationRuleStoreApi = Pick<
|
||||
AutomationRuleStore,
|
||||
'listRules' | 'getRule' | 'createRule' | 'updateRule' | 'deleteRule' | 'setRuleEnabled'
|
||||
>
|
||||
type AutomationExecutionLogApi = Pick<AutomationExecutionLogService, 'list'>
|
||||
type RuleScope = 'any' | 'person' | 'group'
|
||||
|
||||
type GroupExitMonitorStateSource = {
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
monitoredRoomIds?: string[]
|
||||
nativeMonitorActive?: boolean
|
||||
monitoredGroupCount?: number
|
||||
monitorSelectionConfigured?: boolean
|
||||
lastCheckedAt?: number
|
||||
lastReadAt?: number
|
||||
unreadCount?: number
|
||||
totalEventCount?: number
|
||||
events?: GroupExitMonitorEvent[]
|
||||
}
|
||||
|
||||
type GroupExitMonitorConfiguration = {
|
||||
enabled?: boolean
|
||||
monitoredRoomIds?: string[]
|
||||
}
|
||||
|
||||
type GroupExitMonitorEventQuery = {
|
||||
roomId?: string
|
||||
sinceMs?: number
|
||||
untilMs?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export class LocalAgentApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'LocalAgentApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalAgentApiDependencies {
|
||||
automationRuleStore: AutomationRuleStoreApi
|
||||
automationExecutionLogService: AutomationExecutionLogApi
|
||||
listContacts: () => Promise<Contact[]>
|
||||
isDatabaseReady: () => boolean
|
||||
getVersion: () => string
|
||||
getPersonalWechatCapability: () => Promise<PersonalWechatSendCapability>
|
||||
getAgentHubStatus: () => { connector: string }
|
||||
getGroupExitMonitorState: () => GroupExitMonitorStateSource
|
||||
/** Preferred atomic monitor configuration operation. */
|
||||
configureGroupExitMonitor?: (
|
||||
configuration: GroupExitMonitorConfiguration
|
||||
) => Promise<GroupExitMonitorStateSource> | GroupExitMonitorStateSource
|
||||
setGroupExitMonitorRoomIds?: (roomIds: string[]) => Promise<GroupExitMonitorStateSource>
|
||||
setGroupExitMonitorEnabled?: (enabled: boolean) => Promise<GroupExitMonitorStateSource>
|
||||
listGroupExitMonitorEvents?: (query: GroupExitMonitorEventQuery) => GroupExitMonitorEvent[]
|
||||
getGroupMemberStats?: (query: GroupMemberStatsQuery) => Promise<GroupMemberStatsResult>
|
||||
}
|
||||
|
||||
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'scheduled_report', 'leave_notification']
|
||||
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
|
||||
const UPDATE_FIELDS = new Set([
|
||||
'name',
|
||||
'trigger',
|
||||
'scope',
|
||||
'conditions',
|
||||
'actions',
|
||||
'cooldownSeconds',
|
||||
'replyDelaySeconds',
|
||||
'leaveNotification',
|
||||
'scheduledReport'
|
||||
])
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function toDraft(rule: AutomationRule): AutomationRuleDraft {
|
||||
return {
|
||||
name: rule.name,
|
||||
enabled: false,
|
||||
ruleType: rule.ruleType,
|
||||
trigger: rule.trigger,
|
||||
scope: rule.scope,
|
||||
conditions: structuredClone(rule.conditions),
|
||||
actions: structuredClone(rule.actions),
|
||||
cooldownSeconds: rule.cooldownSeconds,
|
||||
replyDelaySeconds: rule.replyDelaySeconds,
|
||||
...(rule.leaveNotification
|
||||
? { leaveNotification: structuredClone(rule.leaveNotification) }
|
||||
: {}),
|
||||
...(rule.scheduledReport ? { scheduledReport: structuredClone(rule.scheduledReport) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function deepMergeDraft(
|
||||
current: AutomationRuleDraft,
|
||||
patch: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...current, ...patch, enabled: false }
|
||||
if (patch.conditions !== undefined) {
|
||||
next.conditions = { ...current.conditions, ...record(patch.conditions) }
|
||||
}
|
||||
if (patch.leaveNotification !== undefined && current.leaveNotification) {
|
||||
const leavePatch = record(patch.leaveNotification) || {}
|
||||
next.leaveNotification = {
|
||||
...current.leaveNotification,
|
||||
...leavePatch,
|
||||
...(leavePatch.target !== undefined ? { target: leavePatch.target } : {})
|
||||
}
|
||||
if (record(leavePatch.target)?.type !== undefined) {
|
||||
delete (next.leaveNotification as Record<string, unknown>).targetNeedsReview
|
||||
}
|
||||
}
|
||||
if (patch.scheduledReport !== undefined && current.scheduledReport) {
|
||||
const schedulePatch = record(patch.scheduledReport) || {}
|
||||
const currentConfig = current.scheduledReport
|
||||
next.scheduledReport = {
|
||||
...currentConfig,
|
||||
...schedulePatch,
|
||||
schedule: { ...currentConfig.schedule, ...record(schedulePatch.schedule) },
|
||||
report: { ...currentConfig.report, ...record(schedulePatch.report) },
|
||||
...(schedulePatch.target !== undefined ? { target: schedulePatch.target } : {})
|
||||
}
|
||||
if (record(schedulePatch.target)?.type !== undefined) {
|
||||
delete (next.scheduledReport as Record<string, unknown>).targetNeedsReview
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function stableId(contact: Contact): string {
|
||||
return contact.type === 'user' ? contact.wxid || contact.m_nsUsrName : contact.m_nsUsrName
|
||||
}
|
||||
|
||||
function toIsoTimestamp(value: number | undefined): string | null {
|
||||
if (!Number.isFinite(value) || Number(value) <= 0) return null
|
||||
const date = new Date(Number(value))
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
export class LocalAgentApiService {
|
||||
private readonly deps: LocalAgentApiDependencies
|
||||
|
||||
constructor(dependencies: LocalAgentApiDependencies) {
|
||||
this.deps = dependencies
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<ApplicationCapabilities> {
|
||||
const databaseReady = this.deps.isDatabaseReady()
|
||||
const hub = this.deps.getAgentHubStatus()
|
||||
const monitor = this.deps.getGroupExitMonitorState()
|
||||
let personal: Pick<
|
||||
PersonalWechatSendCapability,
|
||||
'supported' | 'ready' | 'status' | 'capabilities'
|
||||
>
|
||||
try {
|
||||
personal = await this.deps.getPersonalWechatCapability()
|
||||
} catch {
|
||||
personal = {
|
||||
supported: true,
|
||||
ready: false,
|
||||
status: 'error',
|
||||
capabilities: { text: false, image: false, voice: false }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: this.deps.getVersion(),
|
||||
apiVersion: 'v1',
|
||||
database: { ready: databaseReady },
|
||||
query: {
|
||||
supported: true,
|
||||
available: databaseReady,
|
||||
...(!databaseReady ? { reason: 'database_not_ready' } : {})
|
||||
},
|
||||
automations: {
|
||||
supported: true,
|
||||
available: true,
|
||||
ruleTypes: [...RULE_TYPES],
|
||||
operations: [
|
||||
'list',
|
||||
'get',
|
||||
'create',
|
||||
'update',
|
||||
'validate',
|
||||
'enable',
|
||||
'disable',
|
||||
'delete',
|
||||
'executions'
|
||||
]
|
||||
},
|
||||
groupExitMonitor: {
|
||||
supported: true,
|
||||
available: databaseReady && monitor.running,
|
||||
operations: [
|
||||
'read_state',
|
||||
'configure_scope',
|
||||
'enable',
|
||||
'disable',
|
||||
'list_events'
|
||||
],
|
||||
...(!databaseReady
|
||||
? { reason: 'database_not_ready' }
|
||||
: !monitor.enabled
|
||||
? { reason: 'disabled' }
|
||||
: !monitor.running
|
||||
? { reason: 'not_running' }
|
||||
: {})
|
||||
},
|
||||
groupStats: {
|
||||
supported: true,
|
||||
available: databaseReady,
|
||||
operations: ['member_stats'],
|
||||
...(!databaseReady ? { reason: 'database_not_ready' } : {})
|
||||
},
|
||||
wechat: {
|
||||
personal: {
|
||||
supported: personal.supported,
|
||||
available: personal.ready,
|
||||
status: personal.status,
|
||||
content: { ...personal.capabilities },
|
||||
...(!personal.ready ? { reason: personal.status } : {})
|
||||
},
|
||||
ilink: {
|
||||
supported: true,
|
||||
available: hub.connector === 'online',
|
||||
status: hub.connector,
|
||||
...(hub.connector !== 'online' ? { reason: 'connector_not_online' } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getGroupExitMonitorState(): AgentGroupExitMonitorState {
|
||||
const state = this.deps.getGroupExitMonitorState()
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
running: state.running === true,
|
||||
nativeMonitorActive: state.nativeMonitorActive === true,
|
||||
monitoredConversationIds: [...(state.monitoredRoomIds || [])],
|
||||
monitoredGroupCount: Number.isFinite(state.monitoredGroupCount)
|
||||
? Number(state.monitoredGroupCount)
|
||||
: state.monitoredRoomIds?.length || 0,
|
||||
monitorSelectionConfigured: state.monitorSelectionConfigured !== false,
|
||||
lastCheckedAt: toIsoTimestamp(state.lastCheckedAt),
|
||||
lastReadAt: toIsoTimestamp(state.lastReadAt),
|
||||
eventCount: Number.isFinite(state.totalEventCount)
|
||||
? Number(state.totalEventCount)
|
||||
: state.events?.length || 0,
|
||||
unreadCount: Number.isFinite(state.unreadCount) ? Number(state.unreadCount) : 0
|
||||
}
|
||||
}
|
||||
|
||||
async updateGroupExitMonitor(input: unknown): Promise<AgentGroupExitMonitorState> {
|
||||
const patch = record(input)
|
||||
if (!patch) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体必须是 JSON 对象')
|
||||
}
|
||||
const keys = Object.keys(patch)
|
||||
const allowed = new Set(['enabled', 'monitoredConversationIds'])
|
||||
const unknown = keys.find((key) => !allowed.has(key))
|
||||
if (unknown) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `不支持字段:${unknown}`)
|
||||
}
|
||||
if (keys.length === 0) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '至少提供 enabled 或 monitoredConversationIds')
|
||||
}
|
||||
|
||||
const enabled = patch.enabled
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'enabled 必须是布尔值')
|
||||
}
|
||||
|
||||
let monitoredRoomIds: string[] | undefined
|
||||
if (patch.monitoredConversationIds !== undefined) {
|
||||
if (!Array.isArray(patch.monitoredConversationIds)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'monitoredConversationIds 必须是数组')
|
||||
}
|
||||
monitoredRoomIds = []
|
||||
const seen = new Set<string>()
|
||||
for (const value of patch.monitoredConversationIds) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '监控群 ID 不能为空')
|
||||
}
|
||||
const roomId = value.trim()
|
||||
if (seen.has(roomId)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `监控群 ID 重复:${roomId}`)
|
||||
}
|
||||
if (!roomId.endsWith('@chatroom') || roomId.includes('/') || roomId.includes('\\')) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `无效的群会话 ID:${roomId}`)
|
||||
}
|
||||
seen.add(roomId)
|
||||
monitoredRoomIds.push(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
throw new LocalAgentApiError(409, 'DATABASE_NOT_READY', '数据库未就绪,无法修改退群监控配置')
|
||||
}
|
||||
if (monitoredRoomIds !== undefined) {
|
||||
const contacts = await this.deps.listContacts()
|
||||
const groups = new Set(
|
||||
contacts
|
||||
.filter((contact) => contact.type === 'group')
|
||||
.map((contact) => contact.m_nsUsrName)
|
||||
)
|
||||
const missing = monitoredRoomIds.find((roomId) => !groups.has(roomId))
|
||||
if (missing) {
|
||||
throw new LocalAgentApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
`监控群不存在或不是群联系人:${missing}`,
|
||||
[{ path: 'monitoredConversationIds', code: 'group_not_found', message: missing }]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const configuration: GroupExitMonitorConfiguration = {
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(monitoredRoomIds !== undefined ? { monitoredRoomIds } : {})
|
||||
}
|
||||
let nextState: GroupExitMonitorStateSource | undefined
|
||||
if (this.deps.configureGroupExitMonitor) {
|
||||
nextState = await this.deps.configureGroupExitMonitor(configuration)
|
||||
} else {
|
||||
// Test adapters and older embedders may only expose the two primitive operations.
|
||||
// All validation is complete before this fallback starts mutating state.
|
||||
const previous = this.deps.getGroupExitMonitorState()
|
||||
try {
|
||||
if (monitoredRoomIds !== undefined) {
|
||||
if (!this.deps.setGroupExitMonitorRoomIds) {
|
||||
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群监控配置能力尚未就绪')
|
||||
}
|
||||
nextState = await this.deps.setGroupExitMonitorRoomIds(monitoredRoomIds)
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
if (!this.deps.setGroupExitMonitorEnabled) {
|
||||
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群监控配置能力尚未就绪')
|
||||
}
|
||||
nextState = await this.deps.setGroupExitMonitorEnabled(enabled)
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort rollback for legacy adapters. Production uses the atomic operation above.
|
||||
try {
|
||||
if (monitoredRoomIds !== undefined && this.deps.setGroupExitMonitorRoomIds) {
|
||||
await this.deps.setGroupExitMonitorRoomIds(previous.monitoredRoomIds || [])
|
||||
}
|
||||
if (enabled !== undefined && this.deps.setGroupExitMonitorEnabled) {
|
||||
await this.deps.setGroupExitMonitorEnabled(previous.enabled)
|
||||
}
|
||||
} catch {
|
||||
// Preserve the original error; an adapter that cannot roll back is non-atomic by definition.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return this.toGroupExitMonitorState(nextState || this.deps.getGroupExitMonitorState())
|
||||
}
|
||||
|
||||
listGroupExitMonitorEvents(query: URLSearchParams): {
|
||||
count: number
|
||||
events: AgentGroupExitMonitorEvent[]
|
||||
} {
|
||||
if (!this.deps.listGroupExitMonitorEvents) {
|
||||
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '退群事件查询能力尚未就绪')
|
||||
}
|
||||
const rawConversationId = query.get('conversationId')
|
||||
let roomId: string | undefined
|
||||
if (rawConversationId !== null) {
|
||||
roomId = rawConversationId.trim()
|
||||
if (!roomId || !roomId.endsWith('@chatroom') || roomId.includes('/') || roomId.includes('\\')) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'conversationId 必须是有效的群会话 ID')
|
||||
}
|
||||
}
|
||||
const since = this.parseDateFilter(query.get('since'), 'since')
|
||||
const until = this.parseDateFilter(query.get('until'), 'until')
|
||||
if (since !== undefined && until !== undefined && since > until) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'since 不能晚于 until')
|
||||
}
|
||||
const rawLimit = query.get('limit')
|
||||
const limit = rawLimit === null ? 50 : Number(rawLimit)
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'limit 必须是 1 到 200 之间的整数')
|
||||
}
|
||||
const events = this.deps
|
||||
.listGroupExitMonitorEvents({ roomId, sinceMs: since, untilMs: until, limit })
|
||||
.slice()
|
||||
.sort((left, right) => left.detectedAt - right.detectedAt)
|
||||
.map((event) => this.toAgentGroupExitEvent(event))
|
||||
return { count: events.length, events }
|
||||
}
|
||||
|
||||
async getGroupMemberStats(
|
||||
conversationId: string,
|
||||
query: URLSearchParams
|
||||
): Promise<AgentGroupMemberStats> {
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
throw new LocalAgentApiError(409, 'DATABASE_NOT_READY', '数据库未就绪,无法查询群员统计')
|
||||
}
|
||||
const normalizedConversationId = conversationId.trim()
|
||||
if (!normalizedConversationId || normalizedConversationId.includes('/') || normalizedConversationId.includes('\\')) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'conversationId 格式无效')
|
||||
}
|
||||
const start = this.parseDateFilter(query.get('start'), 'start')
|
||||
const end = this.parseDateFilter(query.get('end'), 'end')
|
||||
if (start === undefined || end === undefined) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'start 和 end 必须同时提供')
|
||||
}
|
||||
if (start > end) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'start 不能晚于 end')
|
||||
}
|
||||
|
||||
const contacts = await this.deps.listContacts()
|
||||
const matches = contacts.filter((contact) => contact.m_nsUsrName === normalizedConversationId)
|
||||
if (matches.length === 0) {
|
||||
throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到指定会话')
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new LocalAgentApiError(409, 'AMBIGUOUS_CONTACT', 'conversationId 匹配到多个会话')
|
||||
}
|
||||
const contact = matches[0]
|
||||
if (contact.type !== 'group') {
|
||||
throw new LocalAgentApiError(422, 'NOT_GROUP_CONVERSATION', 'conversationId 不是群会话')
|
||||
}
|
||||
if (!contact.m_nsUsrName.endsWith('@chatroom')) {
|
||||
throw new LocalAgentApiError(422, 'NOT_GROUP_CONVERSATION', 'conversationId 不是有效的群会话 ID')
|
||||
}
|
||||
if (!this.deps.getGroupMemberStats) {
|
||||
throw new LocalAgentApiError(503, 'CAPABILITY_UNAVAILABLE', '群员统计能力尚未就绪')
|
||||
}
|
||||
|
||||
const result = await this.deps.getGroupMemberStats({
|
||||
userMd5: contact.md5,
|
||||
startTime: start,
|
||||
endTime: end
|
||||
})
|
||||
return this.toAgentGroupMemberStats(contact, result)
|
||||
}
|
||||
|
||||
async listAutomations(filter: {
|
||||
type?: string | null
|
||||
enabled?: string | null
|
||||
}): Promise<unknown[]> {
|
||||
if (filter.type && !RULE_TYPES.includes(filter.type as AutomationRuleType)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'type 不受支持')
|
||||
}
|
||||
let enabled: boolean | undefined
|
||||
if (filter.enabled !== undefined && filter.enabled !== null) {
|
||||
if (filter.enabled !== 'true' && filter.enabled !== 'false') {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'enabled 必须是 true 或 false')
|
||||
}
|
||||
enabled = filter.enabled === 'true'
|
||||
}
|
||||
const rules = this.deps.automationRuleStore
|
||||
.listRules()
|
||||
.filter(
|
||||
(rule) =>
|
||||
(!filter.type || rule.ruleType === filter.type) &&
|
||||
(enabled === undefined || rule.enabled === enabled)
|
||||
)
|
||||
return this.toApiRules(rules)
|
||||
}
|
||||
|
||||
async getAutomation(id: string): Promise<unknown> {
|
||||
const rule = this.deps.automationRuleStore.getRule(id)
|
||||
if (!rule) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
return (await this.toApiRules([rule]))[0]
|
||||
}
|
||||
|
||||
async validateAutomation(input: unknown): Promise<AgentAutomationValidationResult> {
|
||||
const structural = validateAutomationDraftShape(input)
|
||||
if (!structural.valid || !structural.normalized) {
|
||||
return { valid: false, issues: structural.issues }
|
||||
}
|
||||
|
||||
const issues = [...structural.issues]
|
||||
const draft = structuredClone(structural.normalized)
|
||||
const references = await this.resolveDraftReferences(draft, issues)
|
||||
if (issues.length) return { valid: false, issues }
|
||||
|
||||
const config = draft.scheduledReport
|
||||
const nextRunAt = config ? calculateNextRunAt(config.schedule.time) : null
|
||||
const effects = this.describeEffects(draft)
|
||||
return {
|
||||
valid: true,
|
||||
issues: [],
|
||||
normalized: draft,
|
||||
effects,
|
||||
nextRunAt,
|
||||
capabilities: {
|
||||
databaseReady: this.deps.isDatabaseReady(),
|
||||
referencedConversationsResolved: references
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createAutomation(input: unknown): Promise<unknown> {
|
||||
const raw = record(input)
|
||||
if (raw?.ruleType === 'leave_notification') {
|
||||
throw new LocalAgentApiError(409, 'SINGLETON_RULE', '退群通知是系统单例规则,请修改现有规则')
|
||||
}
|
||||
const validation = await this.validateAutomation(input)
|
||||
if (!validation.valid || !validation.normalized) {
|
||||
throw new LocalAgentApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'自动化规则校验失败',
|
||||
validation.issues
|
||||
)
|
||||
}
|
||||
const contacts = await this.deps.listContacts()
|
||||
const storedDraft = this.toStoreDraft(validation.normalized as AutomationRuleDraft, contacts)
|
||||
storedDraft.enabled = false
|
||||
const created = this.deps.automationRuleStore.createRule(storedDraft)
|
||||
return (await this.toApiRules([created]))[0]
|
||||
}
|
||||
|
||||
async updateAutomation(id: string, patchInput: unknown): Promise<unknown> {
|
||||
const current = this.deps.automationRuleStore.getRule(id)
|
||||
if (!current) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
const patch = record(patchInput)
|
||||
if (!patch) throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体必须是 JSON 对象')
|
||||
for (const key of Object.keys(patch)) {
|
||||
if (key === 'ruleType') {
|
||||
throw new LocalAgentApiError(400, 'RULE_TYPE_IMMUTABLE', 'ruleType 不可修改')
|
||||
}
|
||||
if (key === 'id' || key === 'createdAt' || key === 'updatedAt') {
|
||||
throw new LocalAgentApiError(400, 'IMMUTABLE_FIELD', `${key} 不允许由客户端修改`)
|
||||
}
|
||||
if (key === 'enabled') {
|
||||
throw new LocalAgentApiError(400, 'USE_ENABLE_OPERATION', '请使用 enable 或 disable 操作')
|
||||
}
|
||||
if (!UPDATE_FIELDS.has(key)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `不支持字段:${key}`)
|
||||
}
|
||||
}
|
||||
for (const configKey of ['leaveNotification', 'scheduledReport']) {
|
||||
if (Object.hasOwn(record(patch[configKey]) || {}, 'targetNeedsReview')) {
|
||||
throw new LocalAgentApiError(
|
||||
400,
|
||||
'IMMUTABLE_FIELD',
|
||||
`${configKey}.targetNeedsReview 只能通过更新 target 清除`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const apiCurrent = (await this.toApiRules([current]))[0] as AutomationRule & {
|
||||
requiresReview?: boolean
|
||||
}
|
||||
const candidate = deepMergeDraft(toDraft(apiCurrent), patch)
|
||||
const validation = await this.validateAutomation(candidate)
|
||||
if (!validation.valid || !validation.normalized) {
|
||||
throw new LocalAgentApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'自动化规则校验失败',
|
||||
validation.issues
|
||||
)
|
||||
}
|
||||
|
||||
const contacts = await this.deps.listContacts()
|
||||
const storedDraft = this.toStoreDraft(validation.normalized as AutomationRuleDraft, contacts)
|
||||
storedDraft.enabled = current.enabled
|
||||
const updated = this.deps.automationRuleStore.updateRule(id, storedDraft)
|
||||
if (!updated) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
return (await this.toApiRules([updated]))[0]
|
||||
}
|
||||
|
||||
async setAutomationEnabled(id: string, enabled: boolean): Promise<unknown> {
|
||||
const current = this.deps.automationRuleStore.getRule(id)
|
||||
if (!current) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
if (enabled) {
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
throw new LocalAgentApiError(409, 'VALIDATION_FAILED', '数据库未就绪,无法启用自动化规则', [
|
||||
{ path: 'database', code: 'database_not_ready', message: 'TraceMemo 数据库未初始化' }
|
||||
])
|
||||
}
|
||||
const apiCurrent = (await this.toApiRules([current]))[0] as AutomationRule
|
||||
const draft = toDraft(apiCurrent)
|
||||
const validation = await this.validateAutomation(draft)
|
||||
if (!validation.valid) {
|
||||
throw new LocalAgentApiError(
|
||||
409,
|
||||
'VALIDATION_FAILED',
|
||||
'自动化规则校验失败,未启用',
|
||||
validation.issues
|
||||
)
|
||||
}
|
||||
}
|
||||
const updated = this.deps.automationRuleStore.setRuleEnabled(id, enabled)
|
||||
if (!updated) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
return (await this.toApiRules([updated]))[0]
|
||||
}
|
||||
|
||||
async deleteAutomation(id: string): Promise<{ deletedId: string }> {
|
||||
if (id === BUILTIN_DAILY_REPORT_RULE_ID || id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) {
|
||||
throw new LocalAgentApiError(409, 'PROTECTED_RULE', '系统内置自动化规则不能删除')
|
||||
}
|
||||
const rule = this.deps.automationRuleStore.getRule(id)
|
||||
if (!rule) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
if (rule.ruleType === 'leave_notification') {
|
||||
throw new LocalAgentApiError(409, 'PROTECTED_RULE', '退群通知是系统单例规则,不能删除')
|
||||
}
|
||||
const deleted = this.deps.automationRuleStore.deleteRule(id)
|
||||
if (!deleted) throw new LocalAgentApiError(404, 'NOT_FOUND', '未找到自动化规则')
|
||||
return { deletedId: id }
|
||||
}
|
||||
|
||||
listExecutions(query: URLSearchParams): {
|
||||
count: number
|
||||
executions: AgentAutomationExecution[]
|
||||
} {
|
||||
const ruleId = query.get('ruleId') || undefined
|
||||
const status = query.get('status') || undefined
|
||||
if (status && !EXECUTION_STATUSES.includes(status as AutomationExecution['status'])) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'status 不受支持')
|
||||
}
|
||||
const since = this.parseDateFilter(query.get('since'), 'since')
|
||||
const until = this.parseDateFilter(query.get('until'), 'until')
|
||||
if (since !== undefined && until !== undefined && since > until) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'since 不能晚于 until')
|
||||
}
|
||||
const rawLimit = query.get('limit')
|
||||
const limit = rawLimit === null ? 50 : Number(rawLimit)
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', 'limit 必须是 1 到 200 之间的整数')
|
||||
}
|
||||
const rulesById = new Map(
|
||||
this.deps.automationRuleStore.listRules().map((rule) => [rule.id, rule])
|
||||
)
|
||||
const executions = this.deps.automationExecutionLogService
|
||||
.list({ limit: 200 })
|
||||
.filter((item) => !ruleId || item.ruleId === ruleId)
|
||||
.filter((item) => !status || item.status === status)
|
||||
.filter((item) => since === undefined || item.triggerTime >= since)
|
||||
.filter((item) => until === undefined || item.triggerTime <= until)
|
||||
.slice(0, limit)
|
||||
.map((item) => this.toApiExecution(item, rulesById.get(item.ruleId)?.ruleType))
|
||||
return { count: executions.length, executions }
|
||||
}
|
||||
|
||||
private toGroupExitMonitorState(state: GroupExitMonitorStateSource): AgentGroupExitMonitorState {
|
||||
const eventCount = Number.isFinite(state.totalEventCount)
|
||||
? Number(state.totalEventCount)
|
||||
: state.events?.length || 0
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
running: state.running === true,
|
||||
nativeMonitorActive: state.nativeMonitorActive === true,
|
||||
monitoredConversationIds: [...(state.monitoredRoomIds || [])],
|
||||
monitoredGroupCount: Number.isFinite(state.monitoredGroupCount)
|
||||
? Number(state.monitoredGroupCount)
|
||||
: state.monitoredRoomIds?.length || 0,
|
||||
monitorSelectionConfigured: state.monitorSelectionConfigured !== false,
|
||||
lastCheckedAt: toIsoTimestamp(state.lastCheckedAt),
|
||||
lastReadAt: toIsoTimestamp(state.lastReadAt),
|
||||
eventCount,
|
||||
unreadCount: Number.isFinite(state.unreadCount) ? Number(state.unreadCount) : 0
|
||||
}
|
||||
}
|
||||
|
||||
private toAgentGroupExitEvent(event: GroupExitMonitorEvent): AgentGroupExitMonitorEvent {
|
||||
return {
|
||||
eventId: event.id,
|
||||
conversationId: event.roomId,
|
||||
groupName: event.groupName,
|
||||
memberId: event.memberWxid,
|
||||
memberName: event.memberName,
|
||||
wechatName: event.wechatName || '',
|
||||
groupRemark: event.groupRemark || '',
|
||||
contactRemark: event.contactRemark || '',
|
||||
previousCount: event.previousCount,
|
||||
currentCount: event.currentCount,
|
||||
delta: event.delta,
|
||||
message: event.message,
|
||||
detectedAt: new Date(event.detectedAt).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
private toAgentGroupMemberStats(contact: Contact, result: GroupMemberStatsResult): AgentGroupMemberStats {
|
||||
const conversationName =
|
||||
contact.m_nsNickName || contact.remark || contact.wechatNickname || contact.m_nsUsrName
|
||||
return {
|
||||
conversation: { id: contact.m_nsUsrName, name: conversationName },
|
||||
conversationId: contact.m_nsUsrName,
|
||||
range: {
|
||||
start: new Date(result.startTime).toISOString(),
|
||||
end: new Date(result.endTime).toISOString()
|
||||
},
|
||||
memberCount: result.memberCount,
|
||||
activeMemberCount: result.activeMemberCount,
|
||||
silentMemberCount: result.silentMemberCount,
|
||||
activeMembers: result.activeMembers.map((member) => ({
|
||||
memberId: member.senderId,
|
||||
displayName: member.displayName,
|
||||
groupNickname: member.groupNickname,
|
||||
messageCount: member.messageCount,
|
||||
lastMessageAt: toIsoTimestamp(member.lastMessageTime)
|
||||
})),
|
||||
silentMembers: result.silentMembers.map((member) => ({
|
||||
memberId: member.senderId,
|
||||
displayName: member.displayName,
|
||||
groupNickname: member.groupNickname
|
||||
})),
|
||||
freshness: result.freshness,
|
||||
complete: result.complete,
|
||||
limitations: [...result.limitations],
|
||||
unattributedMessages: result.unattributedMessages,
|
||||
excludedSystemMessages: result.excludedSystemMessages,
|
||||
firstMessageAt:
|
||||
toIsoTimestamp(result.firstMessageTime ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private parseDateFilter(value: string | null, name: string): number | undefined {
|
||||
if (value === null) return undefined
|
||||
if (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `${name} 必须是带时区的 ISO-8601 时间`)
|
||||
}
|
||||
const timestamp = Date.parse(value)
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', `${name} 不是有效时间`)
|
||||
}
|
||||
return timestamp
|
||||
}
|
||||
|
||||
private toApiExecution(
|
||||
execution: AutomationExecution,
|
||||
ruleType?: AutomationRuleType
|
||||
): AgentAutomationExecution {
|
||||
return {
|
||||
executionId: execution.executionId,
|
||||
ruleId: execution.ruleId,
|
||||
ruleName: execution.ruleName,
|
||||
...(ruleType ? { ruleType } : {}),
|
||||
...(execution.trigger ? { trigger: execution.trigger } : {}),
|
||||
status: execution.status,
|
||||
triggerTime: execution.triggerTime,
|
||||
durationMs: execution.durationMs,
|
||||
sourceDisplayName: execution.sourceDisplayName,
|
||||
startedAt: new Date(execution.triggerTime).toISOString(),
|
||||
finishedAt:
|
||||
execution.status === 'running'
|
||||
? null
|
||||
: new Date(execution.triggerTime + execution.durationMs).toISOString(),
|
||||
...(execution.errorSummary ? { error: execution.errorSummary } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveDraftReferences(
|
||||
draft: AutomationRuleDraft,
|
||||
issues: AgentAutomationValidationIssue[]
|
||||
): Promise<boolean> {
|
||||
const needsContacts =
|
||||
(draft.ruleType === 'daily_report' && draft.conditions.conversationIds.length > 0) ||
|
||||
draft.ruleType === 'scheduled_report' ||
|
||||
(draft.ruleType === 'leave_notification' &&
|
||||
(draft.leaveNotification?.target.type === 'contact' ||
|
||||
(draft.leaveNotification?.notifyRoomIds.length ?? 0) > 0))
|
||||
if (!needsContacts) return true
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
issues.push({
|
||||
path: 'database',
|
||||
code: 'database_not_ready',
|
||||
message: 'TraceMemo 数据库未初始化,无法解析联系人或群聊 ID'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const contacts = await this.deps.listContacts()
|
||||
let resolved = true
|
||||
const resolve = (query: string, scope: RuleScope, path: string): Contact | undefined => {
|
||||
const direct = contacts.filter((contact) => {
|
||||
const identifiers = [
|
||||
contact.md5,
|
||||
contact.m_nsUsrName,
|
||||
contact.wxid,
|
||||
contact.wechatId,
|
||||
contact.alias
|
||||
]
|
||||
return identifiers.some(
|
||||
(identifier) => identifier?.toLocaleLowerCase() === query.toLocaleLowerCase()
|
||||
)
|
||||
})
|
||||
const scopedDirect = direct.filter(
|
||||
(contact) =>
|
||||
scope === 'any' ||
|
||||
(scope === 'person' ? contact.type === 'user' : contact.type === 'group')
|
||||
)
|
||||
if (scopedDirect.length === 1) return scopedDirect[0]
|
||||
|
||||
const result = resolveContact(query, contacts, scope)
|
||||
if (result.matched && result.conversationId) {
|
||||
return contacts.find((contact) => contact.md5 === result.conversationId)
|
||||
}
|
||||
resolved = false
|
||||
issues.push({
|
||||
path,
|
||||
code: result.ambiguous ? 'ambiguous_contact' : 'contact_not_found',
|
||||
message: result.ambiguous
|
||||
? '标识匹配到多个联系人,请使用稳定 ID'
|
||||
: '无法解析到现有联系人或群聊',
|
||||
...(result.ambiguous
|
||||
? {
|
||||
details: {
|
||||
candidates: result.candidates.map((candidate) => {
|
||||
const contact = contacts.find((item) => item.md5 === candidate.conversationId)
|
||||
return contact
|
||||
? { id: stableId(contact), name: candidate.displayName }
|
||||
: { id: candidate.conversationId, name: candidate.displayName }
|
||||
})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (draft.ruleType === 'daily_report') {
|
||||
const scope: RuleScope =
|
||||
draft.scope === 'group' ? 'group' : draft.scope === 'direct' ? 'person' : 'any'
|
||||
const ids: string[] = []
|
||||
for (const [index, query] of draft.conditions.conversationIds.entries()) {
|
||||
const contact = resolve(query, scope, `conditions.conversationIds[${index}]`)
|
||||
if (contact) ids.push(stableId(contact))
|
||||
}
|
||||
draft.conditions.conversationIds = ids
|
||||
}
|
||||
|
||||
if (draft.ruleType === 'scheduled_report' && draft.scheduledReport) {
|
||||
const source = resolve(
|
||||
draft.scheduledReport.report.sourceConversationId,
|
||||
'group',
|
||||
'scheduledReport.report.sourceConversationId'
|
||||
)
|
||||
if (source) draft.scheduledReport.report.sourceConversationId = source.m_nsUsrName
|
||||
if (draft.scheduledReport.target.type === 'contact') {
|
||||
const target = resolve(
|
||||
draft.scheduledReport.target.contactId || '',
|
||||
'person',
|
||||
'scheduledReport.target.contactId'
|
||||
)
|
||||
if (target) draft.scheduledReport.target.contactId = stableId(target)
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.ruleType === 'leave_notification' && draft.leaveNotification) {
|
||||
if (draft.leaveNotification.target.type === 'contact') {
|
||||
const target = resolve(
|
||||
draft.leaveNotification.target.contactId || '',
|
||||
'person',
|
||||
'leaveNotification.target.contactId'
|
||||
)
|
||||
if (target) draft.leaveNotification.target.contactId = stableId(target)
|
||||
}
|
||||
const ids: string[] = []
|
||||
for (const [index, query] of draft.leaveNotification.notifyRoomIds.entries()) {
|
||||
const group = resolve(query, 'group', `leaveNotification.notifyRoomIds[${index}]`)
|
||||
if (group) ids.push(group.m_nsUsrName)
|
||||
}
|
||||
draft.leaveNotification.notifyRoomIds = ids
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private toStoreDraft(draft: AutomationRuleDraft, contacts: Contact[]): AutomationRuleDraft {
|
||||
const stored = structuredClone(draft)
|
||||
if (stored.ruleType === 'daily_report') {
|
||||
stored.conditions.conversationIds = stored.conditions.conversationIds.map(
|
||||
(id) => contacts.find((contact) => stableId(contact) === id)?.md5 || id
|
||||
)
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
private async toApiRules(rules: AutomationRule[]): Promise<unknown[]> {
|
||||
const contacts = await this.deps.listContacts().catch(() => [])
|
||||
return rules.map((rule) => {
|
||||
const copy = structuredClone(rule) as AutomationRule & { requiresReview?: boolean }
|
||||
if (copy.ruleType === 'daily_report') {
|
||||
copy.conditions.conversationIds = copy.conditions.conversationIds.map((id) =>
|
||||
contacts.find((contact) => contact.md5 === id)
|
||||
? stableId(contacts.find((contact) => contact.md5 === id)!)
|
||||
: id
|
||||
)
|
||||
}
|
||||
if (copy.ruleType === 'scheduled_report' && copy.scheduledReport) {
|
||||
copy.requiresReview = copy.scheduledReport.targetNeedsReview === true
|
||||
delete copy.scheduledReport.legacyTarget
|
||||
delete copy.scheduledReport.legacySourceGroup
|
||||
delete copy.scheduledReport.lastRunAt
|
||||
delete copy.scheduledReport.lastScheduledSlot
|
||||
}
|
||||
if (copy.ruleType === 'leave_notification' && copy.leaveNotification) {
|
||||
copy.requiresReview = copy.leaveNotification.targetNeedsReview === true
|
||||
}
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
private describeEffects(draft: AutomationRuleDraft): Record<string, unknown> {
|
||||
if (draft.ruleType === 'scheduled_report' && draft.scheduledReport) {
|
||||
const config = draft.scheduledReport
|
||||
return {
|
||||
generatesReport: true,
|
||||
sendsWechatMessage: true,
|
||||
sourceConversationId: config.report.sourceConversationId,
|
||||
target: config.target
|
||||
}
|
||||
}
|
||||
if (draft.ruleType === 'leave_notification' && draft.leaveNotification) {
|
||||
return {
|
||||
sendsWechatMessage: true,
|
||||
monitorScope: 'configured_in_group_exit_monitor',
|
||||
notificationScope: draft.leaveNotification.notifyScope,
|
||||
notificationRoomIds:
|
||||
draft.leaveNotification.notifyScope === 'selected'
|
||||
? draft.leaveNotification.notifyRoomIds
|
||||
: undefined,
|
||||
target: draft.leaveNotification.target
|
||||
}
|
||||
}
|
||||
return {
|
||||
actions: draft.actions.filter((action) => action.enabled).map((action) => action.type),
|
||||
sendsWechatMessage: draft.actions.some(
|
||||
(action) => action.enabled && action.type === 'sendReportImage'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,21 @@ function parseBody(bodyText: string, contentType?: string): { json?: unknown; bo
|
||||
return { bodyText }
|
||||
}
|
||||
|
||||
function materializeEndpointPath(pathname: string, query: Record<string, string>): string {
|
||||
return pathname.replace('{conversationId}', encodeURIComponent(query.conversationId || ''))
|
||||
}
|
||||
|
||||
function appendQueryParameters(
|
||||
url: URL,
|
||||
endpointPath: string,
|
||||
entries: Array<[string, string]>
|
||||
): void {
|
||||
for (const [key, value] of entries) {
|
||||
if (endpointPath.includes(`{${key}}`)) continue
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLocalApiCurlCommand(payload: unknown): {
|
||||
success: boolean
|
||||
command?: string
|
||||
@@ -71,18 +86,17 @@ export function buildLocalApiCurlCommand(payload: unknown): {
|
||||
const service = apiServer.getState()
|
||||
const targetHost = requestHost(service.host)
|
||||
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
|
||||
const url = new URL(endpoint.path, `http://${hostPart}:${service.port}`)
|
||||
entries.forEach(([key, value]) => {
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
})
|
||||
const endpointPath = materializeEndpointPath(endpoint.path, query as Record<string, string>)
|
||||
const url = new URL(endpointPath, `http://${hostPart}:${service.port}`)
|
||||
appendQueryParameters(url, endpoint.path, entries)
|
||||
const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication()
|
||||
if (endpointId !== 'health' && !token) {
|
||||
return { success: false, error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态' }
|
||||
}
|
||||
const authHeader = token ? ` -H 'Authorization: Bearer ${token}'` : ''
|
||||
const command =
|
||||
endpoint.method === 'POST'
|
||||
? `curl -X POST '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
|
||||
endpoint.method === 'POST' || endpoint.method === 'PATCH'
|
||||
? `curl -X ${endpoint.method} '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
|
||||
: `curl '${url.toString()}'${authHeader}`
|
||||
return { success: true, command }
|
||||
}
|
||||
@@ -110,10 +124,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
const targetHost = requestHost(service.host)
|
||||
const targetPort = service.port
|
||||
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
|
||||
const url = new URL(endpoint.path, `http://${hostPart}:${targetPort}`)
|
||||
entries.forEach(([key, value]) => {
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
})
|
||||
const endpointPath = materializeEndpointPath(endpoint.path, query as Record<string, string>)
|
||||
const url = new URL(endpointPath, `http://${hostPart}:${targetPort}`)
|
||||
appendQueryParameters(url, endpoint.path, entries)
|
||||
|
||||
if (!service.running) {
|
||||
return {
|
||||
@@ -150,7 +163,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
})
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
if (endpoint.method === 'POST') headers['Content-Type'] = 'application/json'
|
||||
if (endpoint.method === 'POST' || endpoint.method === 'PATCH') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const request = http.request(
|
||||
url,
|
||||
@@ -208,7 +223,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
||||
error: error.message
|
||||
})
|
||||
})
|
||||
if (endpoint.method === 'POST') request.write(body)
|
||||
if (endpoint.method === 'POST' || endpoint.method === 'PATCH') request.write(body)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
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()
|
||||
@@ -0,0 +1,441 @@
|
||||
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,15 +7,28 @@ 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 OneBot, Hook or platform details.
|
||||
* 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.
|
||||
*/
|
||||
export class PersonalWechatCapabilityService {
|
||||
constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {}
|
||||
constructor(
|
||||
private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>,
|
||||
private readonly platform: NodeJS.Platform = process.platform,
|
||||
private readonly getMacStatus: () => Promise<PersonalWechatSenderStatus> = () =>
|
||||
macWechatRuntimeManager.buildSenderStatus()
|
||||
) {}
|
||||
|
||||
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
|
||||
if (this.platform === 'darwin') {
|
||||
return this.fromMacRuntime()
|
||||
}
|
||||
const senderStatus = await this.sender.getStatus()
|
||||
return this.fromSenderStatus(senderStatus)
|
||||
}
|
||||
@@ -49,6 +62,16 @@ 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'
|
||||
@@ -60,6 +83,7 @@ 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 &&
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
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,11 +7,8 @@ import type {
|
||||
PersonalWechatVoiceRuntimeComponent
|
||||
} from '../../shared/personal-wechat-voice-runtime'
|
||||
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
|
||||
import {
|
||||
buildPersonalWechatRuntimeEnvironment,
|
||||
findPersonalWechatRuntime
|
||||
} from './personal-wechat-send-service'
|
||||
import type { RuntimeLayout } from './personal-wechat-send-service'
|
||||
import { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service'
|
||||
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||
import { appLogger } from '../app-logger'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -37,8 +34,8 @@ type CommandRunner = (
|
||||
interface PersonalWechatVoiceEnvironmentServiceOptions {
|
||||
platform?: NodeJS.Platform
|
||||
architecture?: string
|
||||
findRuntime?: () => RuntimeLayout | null
|
||||
buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
||||
isRuntimePresent?: () => boolean
|
||||
buildEnvironment?: () => NodeJS.ProcessEnv
|
||||
runCommand?: CommandRunner
|
||||
now?: () => Date
|
||||
}
|
||||
@@ -100,18 +97,26 @@ 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 findRuntime: () => RuntimeLayout | null
|
||||
private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
||||
private readonly isRuntimePresent: () => boolean
|
||||
private readonly buildEnvironment: () => 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.findRuntime = options.findRuntime || findPersonalWechatRuntime
|
||||
this.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent
|
||||
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
|
||||
this.runCommand = options.runCommand || runCommand
|
||||
this.now = options.now || (() => new Date())
|
||||
@@ -123,8 +128,8 @@ export class PersonalWechatVoiceEnvironmentService {
|
||||
}
|
||||
|
||||
logEnvironmentLine('Checking voice encoding environment')
|
||||
const runtime = this.findRuntime()
|
||||
const environment = this.buildEnvironment(runtime?.root)
|
||||
const runtimeReady = this.isRuntimePresent()
|
||||
const environment = this.buildEnvironment()
|
||||
const python = blankComponent()
|
||||
const pilk = blankComponent()
|
||||
const ffmpeg = blankComponent()
|
||||
@@ -197,26 +202,25 @@ export class PersonalWechatVoiceEnvironmentService {
|
||||
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
|
||||
}
|
||||
|
||||
const ready = Boolean(runtime && python.ready && pilk.ready && ffmpeg.ready)
|
||||
const ready = Boolean(runtimeReady && python.ready && pilk.ready && ffmpeg.ready)
|
||||
const result: PersonalWechatVoiceEncodingEnvironment = {
|
||||
state: ready ? 'ready' : 'incomplete',
|
||||
ready,
|
||||
checkedAt: this.now().toISOString(),
|
||||
runtimeReady: Boolean(runtime),
|
||||
...(runtime ? { runtimeRoot: runtime.root } : {}),
|
||||
runtimeReady,
|
||||
python,
|
||||
pilk,
|
||||
ffmpeg,
|
||||
encoder: pilk.ready
|
||||
? 'pilk'
|
||||
: runtime && python.ready && ffmpeg.ready
|
||||
? 'go-silk'
|
||||
: runtimeReady && python.ready && ffmpeg.ready
|
||||
? 'silk'
|
||||
: 'unavailable',
|
||||
message: ready
|
||||
? '语音编码环境正常,可以使用 pilk 编码'
|
||||
: runtime
|
||||
? '语音编码环境不完整,OneBot 可能回退到 go-silk'
|
||||
: '微信发送组件尚未安装,请先准备 OneBot 运行时'
|
||||
: runtimeReady
|
||||
? '语音编码环境不完整,语音将回退到内置 SILK 编码'
|
||||
: '当前版本暂未提供微信消息发送功能'
|
||||
}
|
||||
logEnvironmentLine(
|
||||
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
|
||||
@@ -245,7 +249,7 @@ export class PersonalWechatVoiceEnvironmentService {
|
||||
pythonExecutable,
|
||||
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
|
||||
{
|
||||
env: this.buildEnvironment(before.runtimeRoot || undefined),
|
||||
env: this.buildEnvironment(),
|
||||
timeout: INSTALL_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
|
||||
@@ -27,8 +27,7 @@ export interface ScheduledReportApiDependencies {
|
||||
| 'deleteTask'
|
||||
| 'setTaskEnabled'
|
||||
| 'runScheduledReportNow'
|
||||
> &
|
||||
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
|
||||
>
|
||||
getCapability: () => Promise<PersonalWechatSendCapability>
|
||||
listContacts: () => FormattedContact[]
|
||||
isDatabaseReady: () => boolean
|
||||
@@ -250,16 +249,20 @@ export class ScheduledReportApiService {
|
||||
return result.data
|
||||
}
|
||||
|
||||
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
|
||||
/**
|
||||
* 「重新发送」不再支持。
|
||||
*
|
||||
* 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG
|
||||
* 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发
|
||||
* 语义,不如**明确返回 501**。
|
||||
* 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。
|
||||
*/
|
||||
async retrySend(_executionId: string): Promise<ScheduledReportExecution> {
|
||||
throw new ScheduledReportApiError(
|
||||
501,
|
||||
'not_supported',
|
||||
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
|
||||
)
|
||||
}
|
||||
|
||||
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
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
@@ -0,0 +1,81 @@
|
||||
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 }
|
||||
@@ -39,9 +39,8 @@ 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 时的回退开关,
|
||||
@@ -130,8 +129,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
showStartupProgress: true,
|
||||
ttsSelectedVoiceId: '',
|
||||
ttsModel: 's2.1-pro-free',
|
||||
keepPersonalWechatProcess: false,
|
||||
windowsWechatPort: '',
|
||||
reportImagePostfixText: '今日日报',
|
||||
queryAgentEnabled: true
|
||||
}
|
||||
|
||||
@@ -150,7 +149,10 @@ export function loadSettings(): AppSettings {
|
||||
if (cache) return cache
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
||||
const stored = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings> & {
|
||||
keepPersonalWechatProcess?: unknown
|
||||
}
|
||||
const { keepPersonalWechatProcess: retiredKeepProcess, ...raw } = stored
|
||||
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||
if (raw.autoLogin === undefined) {
|
||||
const hasSavedDatabaseKey = fs.existsSync(
|
||||
@@ -176,7 +178,7 @@ export function loadSettings(): AppSettings {
|
||||
}
|
||||
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
|
||||
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
|
||||
if (cache.recallProtectionEnabled) {
|
||||
if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) {
|
||||
cache.recallProtectionEnabled = false
|
||||
saveSettings(cache)
|
||||
}
|
||||
@@ -192,7 +194,11 @@ export function loadSettings(): AppSettings {
|
||||
export function saveSettings(next: AppSettings): AppSettings {
|
||||
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
|
||||
// 避免遗留入口或旧版本把它重新打开。
|
||||
cache = { ...next, recallProtectionEnabled: false }
|
||||
const sanitized = { ...next } as AppSettings & {
|
||||
keepPersonalWechatProcess?: unknown
|
||||
}
|
||||
delete sanitized.keepPersonalWechatProcess
|
||||
cache = { ...sanitized, recallProtectionEnabled: false }
|
||||
try {
|
||||
ensureDir()
|
||||
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
||||
|
||||
@@ -5,14 +5,14 @@ import path from 'path'
|
||||
import type {
|
||||
PersonalWechatSendCapability,
|
||||
PersonalWechatSendRequest,
|
||||
PersonalWechatSendResult
|
||||
PersonalWechatSendResult,
|
||||
PersonalWechatSenderStatus
|
||||
} from '../../shared/personal-wechat'
|
||||
import type {
|
||||
PolicyDecision,
|
||||
WechatActionAuditRecord,
|
||||
WechatActionContent,
|
||||
WechatActionErrorCode,
|
||||
WechatActionMemberEventReference,
|
||||
WechatActionRequest,
|
||||
WechatActionResult
|
||||
} from '../../shared/wechat-action'
|
||||
@@ -22,17 +22,40 @@ 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', 'member_left_notification'])
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -43,10 +66,6 @@ interface LoadedAuditState {
|
||||
records: WechatActionAuditRecord[]
|
||||
}
|
||||
|
||||
export interface WechatActionPolicyContext {
|
||||
memberEvent?: WechatActionMemberEventReference
|
||||
}
|
||||
|
||||
const defaultDependencies = (): Required<
|
||||
Pick<
|
||||
WechatActionGatewayDependencies,
|
||||
@@ -76,7 +95,6 @@ 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()
|
||||
@@ -86,20 +104,33 @@ export class WechatActionGateway {
|
||||
this.deps = { ...defaultDependencies(), ...deps }
|
||||
}
|
||||
|
||||
/** 记录退群事件,发送通知前可确认事件所属群聊。 */
|
||||
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 })
|
||||
}
|
||||
/**
|
||||
* 用户确认后的日报发送序列。两步都进入 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 }
|
||||
|
||||
registerMemberEvents(events: WechatActionMemberEventReference[]): void {
|
||||
for (const event of events) this.registerMemberEvent(event)
|
||||
}
|
||||
const postfixText = String(request.postfixText || '').trim()
|
||||
if (!postfixText) return { image }
|
||||
|
||||
clearMemberEvents(): void {
|
||||
this.memberEvents.clear()
|
||||
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 }
|
||||
}
|
||||
|
||||
listAuditRecords(): WechatActionAuditRecord[] {
|
||||
@@ -145,8 +176,7 @@ export class WechatActionGateway {
|
||||
)
|
||||
}
|
||||
|
||||
const eventContext = await this.resolveEventContext(request)
|
||||
const policy = evaluateWechatActionPolicy(request, eventContext)
|
||||
const policy = evaluateWechatActionPolicy(request)
|
||||
if (policy.decision !== 'allow') {
|
||||
return this.finishBlocked(
|
||||
actionId,
|
||||
@@ -240,28 +270,6 @@ 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)
|
||||
@@ -280,9 +288,6 @@ 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}`
|
||||
}
|
||||
@@ -429,10 +434,7 @@ export class WechatActionGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateWechatActionPolicy(
|
||||
request: WechatActionRequest,
|
||||
context: WechatActionPolicyContext = {}
|
||||
): PolicyDecision {
|
||||
export function evaluateWechatActionPolicy(request: WechatActionRequest): PolicyDecision {
|
||||
if (!request || typeof request !== 'object') {
|
||||
return {
|
||||
decision: 'block',
|
||||
@@ -463,29 +465,6 @@ export function evaluateWechatActionPolicy(
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -600,6 +579,32 @@ 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'> {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
}
|
||||
@@ -19,9 +19,9 @@ export interface VoiceSilkMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function validateVoicePcm(
|
||||
pcm: Uint8Array,
|
||||
|
||||
@@ -384,6 +384,79 @@ 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()
|
||||
|
||||
@@ -495,7 +568,7 @@ export class Wcdb4Client {
|
||||
private wcdbStopMonitorPipe: (() => void) | null = null
|
||||
private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null
|
||||
private monitorPipeClient: Socket | null = null
|
||||
private monitorCallback: ((type: string, json: string) => void) | null = null
|
||||
private monitorCallback: ((event: Wcdb4MonitorEvent) => void) | null = null
|
||||
private monitorConnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private monitorReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private monitorPipePath = ''
|
||||
@@ -773,7 +846,7 @@ export class Wcdb4Client {
|
||||
})
|
||||
}
|
||||
|
||||
async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> {
|
||||
async startMonitor(callback: (event: Wcdb4MonitorEvent) => void): Promise<boolean> {
|
||||
if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) {
|
||||
return false
|
||||
}
|
||||
@@ -911,13 +984,7 @@ export class Wcdb4Client {
|
||||
private emitMonitorPayload(rawPayload: string): void {
|
||||
const payload = rawPayload.trim()
|
||||
if (!payload || !this.monitorCallback) return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as { action?: string }
|
||||
this.monitorCallback(parsed.action || 'update', payload)
|
||||
} catch {
|
||||
this.monitorCallback('update', payload)
|
||||
}
|
||||
this.monitorCallback(parseMonitorEvent(payload))
|
||||
}
|
||||
|
||||
private scheduleMonitorReconnect(): void {
|
||||
|
||||
Vendored
+52
-50
@@ -82,26 +82,28 @@ 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,
|
||||
ScheduledReportTask,
|
||||
ScheduledReportUpdateInput
|
||||
ScheduledReportResult
|
||||
} 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 { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||
import type {
|
||||
AutomationExecution,
|
||||
AutomationRule,
|
||||
AutomationRuleDraft,
|
||||
AutomationStatusSummary,
|
||||
ScheduledRuleRunOutcome
|
||||
} from '../shared/automation'
|
||||
import type { ActionLogEntry } from '../shared/action-log'
|
||||
import type {
|
||||
AppUpdateCheckResult,
|
||||
@@ -293,18 +295,37 @@ 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[],
|
||||
notificationRoomIds?: string[]
|
||||
) => Promise<GroupExitMonitorState>
|
||||
setGroupExitMonitorNotificationTemplate: (template: string) => Promise<GroupExitMonitorState>
|
||||
/** 保存**监控范围**。通知配置已迁到「自动化 → 退群通知」。 */
|
||||
setGroupExitMonitorGroups: (roomIds: 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>
|
||||
@@ -521,6 +542,7 @@ declare global {
|
||||
ttsSelectedVoiceId: string
|
||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||
windowsWechatPort: string
|
||||
reportImagePostfixText: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -561,6 +583,7 @@ declare global {
|
||||
ttsSelectedVoiceId: string
|
||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||
windowsWechatPort: string
|
||||
reportImagePostfixText: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -584,6 +607,7 @@ declare global {
|
||||
ttsSelectedVoiceId: string
|
||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||
windowsWechatPort: string
|
||||
reportImagePostfixText: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}>
|
||||
@@ -697,21 +721,11 @@ 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
|
||||
@@ -719,35 +733,23 @@ declare global {
|
||||
sendPersonalWechatMessage: (
|
||||
request: PersonalWechatSendRequest
|
||||
) => Promise<PersonalWechatSendResult>
|
||||
listScheduledReports: () => Promise<ScheduledReportTask[]>
|
||||
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
|
||||
/** 定时日报:「立即执行」——与 scheduler 走同一条执行链路(manual trigger)。 */
|
||||
runScheduledReportRule: (
|
||||
ruleId: string
|
||||
) => Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>
|
||||
/** 定时日报:旧执行记录**只读存档**(旧记录无法无损转换,原样展示)。 */
|
||||
listScheduledReportLegacyExecutions: (
|
||||
ruleId?: 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: (
|
||||
taskId: string
|
||||
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
||||
ruleId: string
|
||||
) => Promise<ScheduledReportResult<ScheduledReportNotification>>
|
||||
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||
|
||||
+72
-78
@@ -52,26 +52,28 @@ import type {
|
||||
} from '../shared/personal-wechat'
|
||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportExecution,
|
||||
ScheduledReportNotification,
|
||||
ScheduledReportNotificationCapability,
|
||||
ScheduledReportNotificationSettings,
|
||||
ScheduledReportNotificationSettingsResult,
|
||||
ScheduledReportResult,
|
||||
ScheduledReportTask,
|
||||
ScheduledReportUpdateInput
|
||||
ScheduledReportResult
|
||||
} 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 { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||
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 { ActionLogEntry } from '../shared/action-log'
|
||||
import type { CacheClearScope, CacheSummary } from '../shared/cache'
|
||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||
@@ -167,29 +169,59 @@ 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[],
|
||||
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),
|
||||
/** 保存**监控范围**。通知配置已迁到自动化规则,这里不再有第二个参数。 */
|
||||
setGroupExitMonitorGroups: (roomIds: string[]): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds),
|
||||
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)
|
||||
@@ -519,10 +551,6 @@ 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:
|
||||
@@ -534,26 +562,6 @@ 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: (
|
||||
@@ -564,44 +572,30 @@ const api = {
|
||||
sendPersonalWechatMessage: (
|
||||
request: PersonalWechatSendRequest
|
||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||
listScheduledReports: (): Promise<ScheduledReportTask[]> =>
|
||||
ipcRenderer.invoke('scheduled-report:list'),
|
||||
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
||||
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
|
||||
/**
|
||||
* 定时日报(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),
|
||||
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
|
||||
ipcRenderer.invoke('scheduled-report:getNotificationSettings'),
|
||||
ipcRenderer.invoke('automation:getScheduledReportNotificationSettings'),
|
||||
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
|
||||
ipcRenderer.invoke('automation:getScheduledReportNotificationCapability'),
|
||||
setScheduledReportNotificationEnabled: (
|
||||
enabled: boolean
|
||||
): Promise<ScheduledReportNotificationSettingsResult> =>
|
||||
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),
|
||||
ipcRenderer.invoke('automation:setScheduledReportNotificationEnabled', enabled),
|
||||
testScheduledReportErrorNotification: (
|
||||
taskId: string
|
||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
||||
ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId),
|
||||
ruleId: string
|
||||
): Promise<ScheduledReportResult<ScheduledReportNotification>> =>
|
||||
ipcRenderer.invoke('automation:testScheduledReportErrorNotification', ruleId),
|
||||
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
||||
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||
|
||||
+84
-33
@@ -18,7 +18,6 @@ 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'
|
||||
@@ -42,10 +41,11 @@ 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, supportsPersonalWechatSend } from './utils/runtime-environment'
|
||||
import { useToast } from './components/ui'
|
||||
import { runtimePlatform } from './utils/runtime-environment'
|
||||
import { Button, useToast } from './components/ui'
|
||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||
import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace'
|
||||
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
|
||||
import {
|
||||
buildContactSearchIndex,
|
||||
@@ -257,6 +257,17 @@ 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)。
|
||||
@@ -268,7 +279,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' | 'scheduled' | 'market'>('today')
|
||||
const [reportSection, setReportSection] = useState<'today' | 'market'>('today')
|
||||
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
||||
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
||||
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
||||
@@ -1639,10 +1650,49 @@ 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')
|
||||
@@ -1901,12 +1951,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}
|
||||
@@ -1926,22 +1976,6 @@ 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"
|
||||
@@ -1952,23 +1986,26 @@ 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
|
||||
@@ -2084,6 +2121,20 @@ 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':
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
@@ -16,12 +18,13 @@ 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 取或:任一存在就说明"这是一次跳转"。 */
|
||||
@@ -36,12 +39,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
contentFilter,
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onReloadAvatars,
|
||||
onLoadOlderMessages,
|
||||
onCreateGroupReport,
|
||||
onOpenTextToSpeechSettings,
|
||||
onOpenPersonalWechatSettings,
|
||||
onOpenLocalIndexSettings,
|
||||
isAiLoading = false,
|
||||
jumpToTime,
|
||||
jumpToMessageId
|
||||
@@ -122,8 +125,20 @@ 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 messages.filter((msg) => {
|
||||
return allMessages.filter((msg) => {
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||
.split(',')
|
||||
.map((type) => type.trim())
|
||||
@@ -132,7 +147,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
||||
return typeMatch && contentMatch
|
||||
})
|
||||
}, [messages, contentFilter])
|
||||
}, [allMessages, contentFilter])
|
||||
|
||||
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
@@ -165,9 +180,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,11 +26,7 @@ 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}`
|
||||
@@ -66,8 +62,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 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" />
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '../ui'
|
||||
import { Button, IconButton, Tooltip, TooltipContent, TooltipTrigger } from '../ui'
|
||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
|
||||
import { AiIcon, RefreshIcon, SearchIcon, SendIcon, StatsIcon } from './icons'
|
||||
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
||||
import { GroupMemberStatsDialog } from '../group-stats/GroupMemberStatsDialog'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
contact: Contact
|
||||
@@ -24,9 +15,10 @@ interface ChatHeaderProps {
|
||||
isAiLoading: boolean
|
||||
onContentFilterChange: (value: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onTestSend: () => void
|
||||
onOpenAiSettings: () => void
|
||||
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
|
||||
onOpenLocalIndexSettings?: () => void
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
@@ -38,11 +30,12 @@ export function ChatHeader({
|
||||
isAiLoading,
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onTestSend,
|
||||
onOpenAiSettings
|
||||
onOpenAiSettings,
|
||||
onOpenLocalIndexSettings
|
||||
}: 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
|
||||
@@ -91,16 +84,20 @@ export function ChatHeader({
|
||||
<IconButton label="刷新聊天记录" variant="ghost" className="h-8 w-8" onClick={onRefresh}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
<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>
|
||||
{/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */}
|
||||
{isGroupChat ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="chat-header-text-action"
|
||||
aria-label="群发言统计"
|
||||
title="群发言统计"
|
||||
onClick={() => setStatsOpen(true)}
|
||||
>
|
||||
<StatsIcon />
|
||||
<span>群发言统计</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{supportsPersonalWechatSend ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -148,6 +145,14 @@ export function ChatHeader({
|
||||
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{isGroupChat ? (
|
||||
<GroupMemberStatsDialog
|
||||
open={statsOpen}
|
||||
onOpenChange={setStatsOpen}
|
||||
contact={contact}
|
||||
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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,5 +1,7 @@
|
||||
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'
|
||||
|
||||
@@ -26,11 +28,17 @@ export function MessageGroup({
|
||||
return (
|
||||
<>
|
||||
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
|
||||
{group.messages.map((message) => (
|
||||
<div key={message.id} className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">{message.content}</div>
|
||||
</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>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,16 +5,14 @@ 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 }
|
||||
|
||||
@@ -36,7 +34,7 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
||||
sipDisabled: false,
|
||||
wechatRunning: false,
|
||||
runtimeReady: false,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpoint: '',
|
||||
endpointReady: false,
|
||||
attachReady: false,
|
||||
baseAddressReady: false,
|
||||
@@ -67,18 +65,14 @@ 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)
|
||||
// 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。
|
||||
// 发送入口只信任当前 native runtime 上报的能力状态。
|
||||
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)
|
||||
@@ -86,23 +80,19 @@ function PersonalWechatMacSendDialog({
|
||||
const closingRef = useRef(false)
|
||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||
const targetId = contact.m_nsUsrName
|
||||
const isBusy = binding || runtimeBusy || sendBusy
|
||||
const isBusy = binding || 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 [nextRuntime, nextSender] = await Promise.all([
|
||||
isMac
|
||||
? window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null)
|
||||
: Promise.resolve(null),
|
||||
window.api.getPersonalWechatSenderStatus()
|
||||
])
|
||||
const nextSender = await window.api.getPersonalWechatSenderStatus()
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setRuntimeStatus(nextRuntime)
|
||||
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
|
||||
setSenderStatus(nextSender)
|
||||
setSessionBound(
|
||||
nextSender.state === 'online' ||
|
||||
@@ -122,13 +112,6 @@ 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(() => {
|
||||
@@ -146,22 +129,6 @@ 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)
|
||||
@@ -232,18 +199,27 @@ 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
|
||||
filePath: initialImage.path,
|
||||
postfixText
|
||||
} 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,
|
||||
{
|
||||
@@ -287,7 +263,7 @@ function PersonalWechatMacSendDialog({
|
||||
{displayName.slice(0, 1)}
|
||||
</div>
|
||||
<div className="personal-wechat-chat-heading">
|
||||
<DialogTitle>文字转语音</DialogTitle>
|
||||
<DialogTitle>{initialImage ? '发送日报图片' : '文字转语音'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
|
||||
</DialogDescription>
|
||||
@@ -319,14 +295,10 @@ 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}
|
||||
@@ -336,6 +308,16 @@ 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>
|
||||
@@ -358,8 +340,13 @@ function PersonalWechatMacSendDialog({
|
||||
{sendError}
|
||||
</div>
|
||||
)}
|
||||
{sendSuccess && (
|
||||
<div className="personal-wechat-global-success" role="status">
|
||||
{sendSuccess}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{setupReady && isMac && (
|
||||
{setupReady && isMac && !initialImage && (
|
||||
<div className="personal-wechat-chat-footer flex items-center justify-end">
|
||||
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
|
||||
语音发送诊断
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||
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'
|
||||
import { Button } from '../ui'
|
||||
|
||||
const GROUP_README_URL = 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
|
||||
|
||||
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
|
||||
@@ -27,21 +18,18 @@ 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 isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
|
||||
return '请先完成发送运行时准备。'
|
||||
}
|
||||
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
||||
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
|
||||
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
|
||||
}
|
||||
|
||||
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
||||
@@ -54,28 +42,21 @@ function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
||||
}
|
||||
|
||||
export function PersonalWechatSetupGuide({
|
||||
runtimeStatus,
|
||||
senderStatus,
|
||||
runtimeProgress,
|
||||
runtimeBusy,
|
||||
binding,
|
||||
detecting,
|
||||
sessionBound,
|
||||
onDownloadRuntime,
|
||||
onBind,
|
||||
onStartSending,
|
||||
onOpenTextToSpeechSettings
|
||||
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
||||
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
||||
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
|
||||
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
||||
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
||||
const runtimeUnavailable = senderStatus?.state === 'runtime_missing'
|
||||
const runtimeLabel = '发送运行时'
|
||||
const runtimeReady = senderStatus?.runtimeReady === true
|
||||
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="微信消息功能配置">
|
||||
@@ -89,38 +70,21 @@ export function PersonalWechatSetupGuide({
|
||||
</div>
|
||||
|
||||
<ol className="personal-wechat-steps">
|
||||
<li
|
||||
className={runtimeReady ? 'is-complete' : runtimeDownloading ? 'is-active' : 'is-current'}
|
||||
>
|
||||
<li className={runtimeReady ? 'is-complete' : 'is-current'}>
|
||||
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
||||
<div className="personal-wechat-step-content">
|
||||
<strong>准备 {runtimeLabel}</strong>
|
||||
<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>
|
||||
<p>
|
||||
{runtimeUnavailable
|
||||
? `个人微信发送需要${runtimeLabel},授权后即可使用。`
|
||||
: `${runtimeLabel}随 TraceMemo 一起提供,无需额外安装。`}
|
||||
</p>
|
||||
{runtimeReady ? (
|
||||
<span className="personal-wechat-step-status">✓ {runtimeLabel}已就绪</span>
|
||||
) : runtimeUnavailable ? (
|
||||
<span className="personal-wechat-step-error">当前版本暂未提供微信消息发送功能</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDownloadRuntime}
|
||||
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
|
||||
>
|
||||
下载运行时
|
||||
</Button>
|
||||
)}
|
||||
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
|
||||
<p className="personal-wechat-step-error">
|
||||
运行时准备失败,请重试或查看微信发送设置。
|
||||
</p>
|
||||
<span className="personal-wechat-step-status">正在检查 {runtimeLabel}…</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
@@ -139,7 +103,7 @@ export function PersonalWechatSetupGuide({
|
||||
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
|
||||
<div className="personal-wechat-step-content">
|
||||
<strong>绑定个人微信</strong>
|
||||
<p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
|
||||
<p>请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。</p>
|
||||
{connected ? (
|
||||
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
|
||||
) : (
|
||||
@@ -147,15 +111,28 @@ export function PersonalWechatSetupGuide({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onBind}
|
||||
disabled={!runtimeReady || binding || runtimeDownloading}
|
||||
disabled={runtimeUnavailable || !runtimeReady || binding}
|
||||
>
|
||||
{binding ? '正在绑定…' : '绑定微信'}
|
||||
</Button>
|
||||
)}
|
||||
{!connected && senderStatus && (
|
||||
{!connected && senderStatus && !runtimeUnavailable && (
|
||||
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
|
||||
)}
|
||||
{!connected && (
|
||||
{!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 && (
|
||||
<p className="personal-wechat-step-warning" role="note">
|
||||
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
||||
<br />
|
||||
@@ -163,14 +140,6 @@ export function PersonalWechatSetupGuide({
|
||||
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-fit"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => setShowSupportedVersions(true)}
|
||||
>
|
||||
查看支持的微信版本
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -225,7 +194,7 @@ export function PersonalWechatSetupGuide({
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
|
||||
{!runtimeReady && runtimeUnavailable && onOpenTextToSpeechSettings && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
@@ -235,54 +204,6 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,6 +6,8 @@ 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 {
|
||||
@@ -45,7 +47,7 @@ function statusLabel(status: PersonalWechatSenderStatus | null): string {
|
||||
}
|
||||
|
||||
function statusText(value: string): string {
|
||||
return value.replace(/OneBot|Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
|
||||
return value.replace(/Hook/gi, '微信发送能力').replace(/个人微信发送组件/g, '微信发送能力')
|
||||
}
|
||||
|
||||
function statusDescription(status: PersonalWechatSenderStatus | null): string {
|
||||
@@ -64,12 +66,17 @@ 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
|
||||
@@ -103,9 +110,7 @@ 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({
|
||||
@@ -137,15 +142,28 @@ 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
|
||||
filePath: initialImage.path,
|
||||
postfixText
|
||||
} satisfies PersonalWechatSendRequest)
|
||||
setStatus(response.status)
|
||||
if (!response.success) return
|
||||
if (!response.success) {
|
||||
setSendError(response.error || '日报图片发送失败')
|
||||
return
|
||||
}
|
||||
if (response.postfixError) {
|
||||
setSendError(`日报图片已发送,但${response.postfixError}`)
|
||||
setSendSuccess('日报图片发送成功')
|
||||
} else {
|
||||
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
|
||||
}
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
@@ -156,6 +174,8 @@ export function PersonalWechatWindowsSendDialog({
|
||||
outgoing: true
|
||||
}
|
||||
])
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setSendBusy(false)
|
||||
}
|
||||
@@ -183,7 +203,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>
|
||||
@@ -201,7 +221,7 @@ export function PersonalWechatWindowsSendDialog({
|
||||
</div>
|
||||
|
||||
<div className="personal-wechat-send-target">
|
||||
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
||||
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
||||
<strong>{displayName}</strong>
|
||||
<code>{targetId}</code>
|
||||
</div>
|
||||
@@ -246,7 +266,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>
|
||||
))}
|
||||
@@ -255,6 +275,16 @@ 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>
|
||||
@@ -271,6 +301,16 @@ 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>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -34,12 +34,12 @@ export function ExportIcon({ className }: IconProps): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
export function MoreIcon({ className }: IconProps): React.ReactElement {
|
||||
export function StatsIcon({ className }: IconProps): React.ReactElement {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="5" cy="12" r="1.4" />
|
||||
<circle cx="12" cy="12" r="1.4" />
|
||||
<circle cx="19" cy="12" r="1.4" />
|
||||
<path d="M5.5 19v-6" />
|
||||
<path d="M12 19V5" />
|
||||
<path d="M18.5 19v-9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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,6 +27,7 @@ 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 =>
|
||||
@@ -44,6 +45,8 @@ 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'
|
||||
)
|
||||
@@ -64,24 +67,30 @@ 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>(() => (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 [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 [includeMedia, setIncludeMedia] = useState(true)
|
||||
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
|
||||
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
|
||||
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 [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 [status, setStatus] = useState<ExportStatus>('idle')
|
||||
const [jobId, setJobId] = useState('')
|
||||
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
||||
@@ -92,6 +101,42 @@ 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]
|
||||
@@ -206,7 +251,6 @@ export function ExportWorkspace({
|
||||
const handleSelectContact = (contact: Contact): void => {
|
||||
if (exportAll) {
|
||||
setExportAll(false)
|
||||
setRange('today')
|
||||
}
|
||||
if (!selectionMode) {
|
||||
setSelectedContacts([contact])
|
||||
@@ -236,7 +280,6 @@ export function ExportWorkspace({
|
||||
setExportAll(true)
|
||||
setAllContactTypes([...ALL_CONTACT_TYPES])
|
||||
setSelectionMode(false)
|
||||
setRange('all')
|
||||
setStatus('idle')
|
||||
}
|
||||
|
||||
@@ -417,6 +460,7 @@ export function ExportWorkspace({
|
||||
setSelectedKinds(new Set(['text']))
|
||||
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||
setIncludeMedia(true)
|
||||
setIncludeVoiceTranscripts(true)
|
||||
setIncludeAvatars(true)
|
||||
setPreferOriginal(true)
|
||||
setFallbackThumbnail(true)
|
||||
@@ -424,6 +468,7 @@ export function ExportWorkspace({
|
||||
setFormat('csv')
|
||||
setZip(false)
|
||||
setFileName('')
|
||||
setOutputDirectory('')
|
||||
setStatus('idle')
|
||||
setJobId('')
|
||||
setProgress(null)
|
||||
@@ -492,7 +537,7 @@ export function ExportWorkspace({
|
||||
selectionMode={selectionMode}
|
||||
exportContactCount={exportContacts.length}
|
||||
format={format}
|
||||
range={range}
|
||||
range={exportAll ? 'all' : range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
selectedKinds={selectedKinds}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
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,6 +54,13 @@ 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,6 +3,7 @@ export type AppPage =
|
||||
| 'search'
|
||||
| 'report'
|
||||
| 'exit-monitor'
|
||||
| 'automation'
|
||||
| 'agent-hub'
|
||||
| 'export'
|
||||
| 'api'
|
||||
@@ -18,6 +19,7 @@ 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,7 +4,13 @@ import { Checkbox } from '../ui'
|
||||
|
||||
interface MessageTypeSelectorProps {
|
||||
value: SummaryMessageType[]
|
||||
counts: Record<SummaryMessageType, number>
|
||||
/**
|
||||
* 每类消息的条数。
|
||||
*
|
||||
* 可选:`自动化 → 定时日报` 的编辑器没有真实消息统计,
|
||||
* 不传就不显示数字,而不是显示一排假的 0。
|
||||
*/
|
||||
counts?: Record<SummaryMessageType, number>
|
||||
disabled: boolean
|
||||
onChange: (value: SummaryMessageType[]) => void
|
||||
}
|
||||
@@ -49,7 +55,7 @@ export function MessageTypeSelector({
|
||||
<b>{option.label}</b>
|
||||
<small>{option.description}</small>
|
||||
</span>
|
||||
<em>{counts[option.value]}</em>
|
||||
{counts ? <em>{counts[option.value]}</em> : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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,6 +58,67 @@ export const API_ENDPOINTS: ApiEndpoint[] = [
|
||||
{ key: 'q', label: '待解析标识', required: true, placeholder: '昵称、wxid 或 md5' }
|
||||
]
|
||||
}),
|
||||
endpoint('app-capabilities', {
|
||||
name: '应用能力',
|
||||
description: '查看数据库、Query、Automation 与运行环境的能力状态。'
|
||||
}),
|
||||
endpoint('group-exit-monitor', {
|
||||
name: '退群监控状态',
|
||||
description: '查看监控开关、监控群范围、事件数量和运行状态。'
|
||||
}),
|
||||
endpoint('group-exit-monitor-update', {
|
||||
name: '配置退群监控',
|
||||
description: '设置监控群范围或启用/关闭退群监控,不会修改退群通知 Automation。',
|
||||
body: true
|
||||
}),
|
||||
endpoint('group-exit-events', {
|
||||
name: '退群事件历史',
|
||||
description: '按群和时间窗口查询退群事件,最多返回 200 条。',
|
||||
parameters: [
|
||||
{ key: 'conversationId', label: '群会话 ID', placeholder: 'xxx@chatroom' },
|
||||
{ key: 'since', label: '开始时间', placeholder: '2026-10-01T00:00:00+07:00' },
|
||||
{ key: 'until', label: '结束时间', placeholder: '2026-10-02T23:59:59+07:00' },
|
||||
{ key: 'limit', label: '数量上限', placeholder: '50,最大 200' }
|
||||
]
|
||||
}),
|
||||
endpoint('group-member-stats', {
|
||||
name: '群成员活跃统计',
|
||||
description: '查询指定群在时间窗口内的活跃成员、沉默成员和数据完整性。',
|
||||
parameters: [
|
||||
{ key: 'conversationId', label: '群会话 ID', required: true, placeholder: 'xxx@chatroom' },
|
||||
{ key: 'start', label: '开始时间', required: true, placeholder: '2026-10-01T00:00:00+07:00' },
|
||||
{ key: 'end', label: '结束时间', required: true, placeholder: '2026-10-02T23:59:59+07:00' }
|
||||
]
|
||||
}),
|
||||
endpoint('automations', {
|
||||
name: '自动化规则',
|
||||
description: '列出自动化规则,可按类型和启用状态筛选。',
|
||||
parameters: [
|
||||
{ key: 'type', label: '规则类型', placeholder: 'daily_report / scheduled_report / leave_notification' },
|
||||
{ key: 'enabled', label: '启用状态', placeholder: 'true 或 false' }
|
||||
]
|
||||
}),
|
||||
endpoint('automation-create', {
|
||||
name: '创建自动化规则',
|
||||
description: '创建一条默认停用的自动化规则。',
|
||||
body: true
|
||||
}),
|
||||
endpoint('automation-validate', {
|
||||
name: '校验自动化规则',
|
||||
description: '检查规则字段、目标标识和预计影响,不保存也不执行。',
|
||||
body: true
|
||||
}),
|
||||
endpoint('automation-executions', {
|
||||
name: '自动化执行记录',
|
||||
description: '分页上限内查询执行结果,可按规则、状态和时间筛选。',
|
||||
parameters: [
|
||||
{ key: 'ruleId', label: '规则 ID', placeholder: 'ruleId' },
|
||||
{ key: 'status', label: '状态', placeholder: 'running / success / failed' },
|
||||
{ key: 'since', label: '开始时间', placeholder: '2026-10-01T00:00:00+07:00' },
|
||||
{ key: 'until', label: '结束时间', placeholder: '2026-10-02T23:59:59+07:00' },
|
||||
{ key: 'limit', label: '数量上限', placeholder: '50,最大 200' }
|
||||
]
|
||||
}),
|
||||
endpoint('report', {
|
||||
name: '群聊日报导出',
|
||||
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ApiMethod = 'GET' | 'POST'
|
||||
export type ApiMethod = 'GET' | 'POST' | 'PATCH'
|
||||
export type { ApiTokenStatus } from '../../../../../shared/local-api-auth'
|
||||
|
||||
export interface ApiParameter {
|
||||
|
||||
@@ -4,8 +4,19 @@ export function buildApiUrl(
|
||||
path: string,
|
||||
params: Record<string, string>
|
||||
): string {
|
||||
const url = new URL(path, `http://${host}:${port}`)
|
||||
let resolvedPath = path
|
||||
const pathParameterKeys = new Set(
|
||||
Object.keys(params).filter((key) => path.includes(`{${key}}`))
|
||||
)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = value.trim()
|
||||
if (normalized && resolvedPath.includes(`{${key}}`)) {
|
||||
resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(normalized))
|
||||
}
|
||||
})
|
||||
const url = new URL(resolvedPath, `http://${host}:${port}`)
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (pathParameterKeys.has(key)) return
|
||||
if (value.trim()) url.searchParams.set(key, value.trim())
|
||||
})
|
||||
return url.toString()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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
Reference in New Issue
Block a user