mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e24e5fe6e8 | ||
|
|
194dd0035b | ||
|
|
c3629255ea | ||
|
|
345a0db49b | ||
|
|
5276bce060 | ||
|
|
0abbda1160 | ||
|
|
97c74859c0 | ||
|
|
4d5c6459fe | ||
|
|
c835d5c570 | ||
|
|
59872daf52 | ||
|
|
3bd3935042 | ||
|
|
77a6912ade | ||
|
|
4b83aaed61 | ||
|
|
7268188e40 | ||
|
|
069d836dea | ||
|
|
95384a4a92 | ||
|
|
9b82ca663b | ||
|
|
f5633a29cb | ||
|
|
766cd94788 |
+2
-1
@@ -10,7 +10,8 @@ out
|
|||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
resources/connectors/wechat-personal/
|
resources/runtime/darwin-arm64
|
||||||
|
.native-runtime-source
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|||||||
@@ -25,16 +25,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/日报.png" alt="TraceMemo 主界面" />
|
<img src="./public/日报.png" alt="TraceMemo 日报" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
|
<img src="./public/自动化.png" alt="TraceMemo 自动化" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="./public/退群监控.png" alt="TraceMemo 退群监控" />
|
|
||||||
</p>
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎨 社区日报模板
|
## 🎨 社区日报模板
|
||||||
@@ -49,7 +47,7 @@ TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多
|
|||||||
|
|
||||||
在 TraceMemo 中打开:
|
在 TraceMemo 中打开:
|
||||||
|
|
||||||
**日报 → 今日日报 → 日报模板 → 模板市场**
|
**日报 → 社区模板市场**
|
||||||
|
|
||||||
即可查看、预览、安装和切换已发布的社区模板。
|
即可查看、预览、安装和切换已发布的社区模板。
|
||||||
|
|
||||||
@@ -67,18 +65,21 @@ TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数
|
|||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
|
- 💬 **聊天档案与搜索**:浏览会话,按关键词、备注、昵称或 wxid 查找消息。
|
||||||
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
|
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
|
||||||
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
|
- 🧠 **本地知识库**:在本机建立索引,让跨会话、跨时间的查询更稳定。
|
||||||
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
|
- 🖼️ **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图),识别结果可以在搜索和「问问微信」里被检索。识别全程不联网,原始图片不会因为本地识别而上传。
|
||||||
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
|
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结,可保存为 HTML 与 PNG。
|
||||||
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
|
- 🗣️ **群发言统计**:统计群成员的发言量和沉默成员,看清一个群里谁在说、谁一直没说。
|
||||||
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
|
- 👀 **退群监控**:用成员快照对比记录群成员退出事件,支持多群与事件历史。
|
||||||
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
|
- ⚙️ **自动化**:把上面几步按规则串起来——定时生成并发送日报、成员退群时发送通知;能发到哪里取决于当前的发送能力。
|
||||||
|
- 🔊 **文字转语音**:把文字生成语音,试听后发送到当前会话。
|
||||||
|
- 🤖 **Agent Hub**:在微信里向本机 TraceMemo 提问。
|
||||||
|
- 🔌 **外部 Agent / Local HTTP API**:让 Codex 等外部 Agent 查询本机微信历史。
|
||||||
|
|
||||||
## 💻 平台支持
|
## 💻 平台支持
|
||||||
|
|
||||||
TraceMemo 2.4.0 支持:
|
TraceMemo 2.5.0 支持:
|
||||||
|
|
||||||
- **Windows x64**
|
- **Windows x64**
|
||||||
- **macOS Apple Silicon(M 系列 / arm64)**
|
- **macOS Apple Silicon(M 系列 / arm64)**
|
||||||
@@ -86,6 +87,12 @@ TraceMemo 2.4.0 支持:
|
|||||||
|
|
||||||
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
|
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
|
||||||
|
|
||||||
|
### 关于“发送能力”
|
||||||
|
|
||||||
|
浏览、搜索、日报生成、导出、知识库和图片文字索引都不需要额外的发送组件。只有**把内容真正发回微信**这一步——自动发送日报、退群通知、把语音发到会话——依赖本机发送能力:
|
||||||
|
|
||||||
|
发送能力未就绪、未绑定或发送失败时,报告本身仍会正常生成并保存在本机,执行记录会显示为“已生成,但未发送”或“已生成,发送失败”,可以稍后重试。
|
||||||
|
|
||||||
## 项目缘起
|
## 项目缘起
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -138,11 +145,14 @@ TraceMemo 最早叫 **WechatExplorer**。
|
|||||||
| 想做什么 | 使用入口 |
|
| 想做什么 | 使用入口 |
|
||||||
| ---------------------------------- | ----------------------------- |
|
| ---------------------------------- | ----------------------------- |
|
||||||
| 找记得原文或关键词的消息 | 档案搜索 |
|
| 找记得原文或关键词的消息 | 档案搜索 |
|
||||||
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
|
| 找记得大意、但不知道在哪聊过的内容 | 问问微信(AI Search) |
|
||||||
|
| 找到截图、公告图里写过的文字 | 问问微信 → 图片文字索引 |
|
||||||
| 长期跨群查询历史 | 本地知识库 |
|
| 长期跨群查询历史 | 本地知识库 |
|
||||||
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
|
| 了解一个群今天或近 7 天聊了什么 | 日报 |
|
||||||
|
| 看群里谁最活跃、谁一直没说话 | 档案 → 群聊 → 群发言统计 |
|
||||||
| 持续关注群成员退出 | 退群监控 |
|
| 持续关注群成员退出 | 退群监控 |
|
||||||
| 按计划生成并发送群聊日报 | 定时日报 |
|
| 按计划自动生成并发送群聊日报 | 自动化 |
|
||||||
|
| 成员退群时自动发一条通知 | 自动化 → 退群通知 |
|
||||||
| 把文字生成微信语音 | 文字转语音 |
|
| 把文字生成微信语音 | 文字转语音 |
|
||||||
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
|
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
|
||||||
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
|
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
|
||||||
@@ -159,9 +169,9 @@ TraceMemo 最早叫 **WechatExplorer**。
|
|||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- [用户指南](./docs/README.md#用户指南)
|
- [用户指南](./docs/README.md#档案与搜索)
|
||||||
- [AI / Knowledge](./docs/README.md#ai-与知识库)
|
- [AI / Knowledge](./docs/README.md#ai-与知识库)
|
||||||
- [Monitor / Automation](./docs/README.md#日报与自动化)
|
- [日报与自动化](./docs/README.md#日报与自动化)
|
||||||
- [Agent / API](./docs/README.md#agent--api)
|
- [Agent / API](./docs/README.md#agent--api)
|
||||||
- [开发文档](./docs/development/overview.md)
|
- [开发文档](./docs/development/overview.md)
|
||||||
- [隐私与安全](./docs/user-guide/privacy.md)
|
- [隐私与安全](./docs/user-guide/privacy.md)
|
||||||
@@ -213,7 +223,7 @@ TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.co
|
|||||||
|
|
||||||
这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
|
这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
|
||||||
|
|
||||||
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。
|
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能
|
||||||
|
|
||||||
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
|
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
|
||||||
|
|
||||||
|
|||||||
+7
-8
@@ -6,29 +6,29 @@
|
|||||||
|
|
||||||
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
|
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
|
||||||
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
|
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
|
||||||
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
|
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口,以及群聊里的「群发言统计」。
|
||||||
|
|
||||||
## AI 与知识库
|
## AI 与知识库
|
||||||
|
|
||||||
- [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
|
- [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
|
||||||
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
|
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性;也包括在本机识别图片文字、让截图和公告图变得可搜索的「图片文字索引」。
|
||||||
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
|
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
|
||||||
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
|
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
|
||||||
|
|
||||||
## 日报与自动化
|
## 日报与自动化
|
||||||
|
|
||||||
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
|
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以在「自动化」里创建定时日报。
|
||||||
- 定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
|
- 「自动化」按三类规则执行:**@我生成日报**、**定时日报**、**退群通知**。定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
|
||||||
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./concepts/how-it-works.md#动作执行与审计)。
|
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./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/voice.md):在本机转写微信语音,结果可用于搜索、Knowledge 和导出。
|
||||||
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊。
|
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊;实际发送依赖本机发送能力。
|
||||||
|
|
||||||
## Agent / API
|
## Agent / API
|
||||||
|
|
||||||
@@ -61,7 +61,6 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
|
|||||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||||
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
||||||
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/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 天的聊天;
|
- 查看最近会话:数量限制为 1–20;
|
||||||
- 生成今天、昨天或近 7 天的群聊总结图片;
|
- 生成群聊总结图片:今天、昨天或近 7 天(需要同时提到“群”和“图片 / 长图 / 日报 / 报告”);
|
||||||
- 总结指定群成员在群里的近期发言;
|
- 分析某个群成员的近期发言:可以指定“今天 / 昨天 / 最近 N 天”。
|
||||||
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
|
|
||||||
|
**其余问题**交给本机的 Query Agent:它和桌面端“问问微信”使用的是同一个实现,可以按需读取联系人、会话和时间范围来回答,必要时调用你在“设置 → AI 模型”里配置的 AI。例如“帮我看看最近跟某人聊了些什么”“上个月讨论过的项目地址在哪里”。
|
||||||
|
|
||||||
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
|
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
|
||||||
|
|
||||||
@@ -56,12 +57,12 @@ Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支
|
|||||||
|
|
||||||
## 安全与边界
|
## 安全与边界
|
||||||
|
|
||||||
- Hub 使用本机通信,不把数据库直接暴露到公网;
|
- Hub 在主进程内运行,不开放本地监听端口;它不会把数据库暴露到公网;
|
||||||
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
|
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
|
||||||
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
|
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
|
||||||
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider;
|
- Hub 理解请求或生成总结时,会调用你在“设置 → AI 模型”配置的 Provider;
|
||||||
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
|
- 当前实时入口只处理文字消息。连接器会归一化收到的消息条目,但 Agent Hub 只把文本条目当作意图处理,尚未为图片、语音、文件和视频提供同等能力;
|
||||||
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
|
- 当前没有实现群发、广播、定时任务或通用自主操作微信(定时日报属于「自动化」,不是 Agent Hub);
|
||||||
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
|
- 本页面的“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 的服务。
|
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
|
||||||
|
|
||||||
|
它同时包含**写入型**端点:生成报告并渲染 PNG(`/report`)、通过已连接机器人发送微信消息(`/agent/send`)、创建/修改/删除/启停定时日报任务并触发立即执行(`/scheduled-reports*`)。因此这个 Token 相当于本机敏感凭据,而不是一个只读查询键。
|
||||||
|
|
||||||
## Bearer Token
|
## Bearer Token
|
||||||
|
|
||||||
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
|
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。历史变量名 `WECHATEXPLORER_API_TOKEN` 仍被兼容读取,优先级为新变量高于旧变量;当前没有设定旧变量名的移除时间,新配置不要再使用它。
|
||||||
|
|
||||||
- `/api/v1/health` 是公开健康检查;
|
- `/api/v1/health` 是公开健康检查;
|
||||||
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
||||||
@@ -14,7 +16,8 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑
|
|||||||
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
|
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
|
||||||
- 文件权限设置为 `0600`;
|
- 文件权限设置为 `0600`;
|
||||||
- 在“API Center”中可以显示、复制和重新生成;
|
- 在“API Center”中可以显示、复制和重新生成;
|
||||||
- 重新生成后旧 Token 立即失效。
|
- 重新生成后旧 Token 立即失效;
|
||||||
|
- 服务端只认这个 Token,**不接受用环境变量覆盖**——Agent 一侧的环境变量只是把 Token 交给 Agent 自己的方式,不是鉴权来源。
|
||||||
|
|
||||||
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
||||||
|
|
||||||
|
|||||||
+46
-27
@@ -24,31 +24,48 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
|||||||
|
|
||||||
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
|
不要把 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/health` | 服务与数据库健康状态 | 无 |
|
||||||
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
||||||
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
||||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||||
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
|
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
|
||||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||||
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
||||||
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
||||||
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
|
| 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` | 复用已有 PNG 重试发送 | 无 |
|
||||||
|
|
||||||
|
`/api/v1/query/*` 是一组结构化的 Query 端点,见下方[LLM-friendly Query Tool API](#llm-friendly-query-tool-api)。
|
||||||
|
|
||||||
### 这些端点与实时机器人有什么关系
|
### 这些端点与实时机器人有什么关系
|
||||||
|
|
||||||
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
|
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
|
||||||
- `/api/v1/agent/group-report` 由外部 Agent 或脚本主动请求生成群聊总结图片;
|
- `/api/v1/agent/group-report` 由外部 Agent 或脚本主动请求生成群聊总结图片;
|
||||||
- `/api/v1/agent/send` 是受 Bearer Token 保护的开发者/测试发送入口,用于通过已经连接的机器人发送文字或本地图片;它不是任意群发能力,也不是实时消息订阅接口;
|
- `/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 和微信连接器接收、处理和回复。
|
- 当前 API 没有对外暴露实时入站 webhook。微信消息由应用内部的 Agent Hub 和微信连接器接收、处理和回复。
|
||||||
|
|
||||||
## 时间查询
|
## 时间查询
|
||||||
@@ -83,10 +100,12 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
|||||||
## 响应与错误
|
## 响应与错误
|
||||||
|
|
||||||
- `200`:请求成功;
|
- `200`:请求成功;
|
||||||
|
- `201`:定时日报任务创建成功;
|
||||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||||
- `400`:参数或 JSON 请求体无效;
|
- `400`:参数或 JSON 请求体无效;
|
||||||
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
|
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
|
||||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||||
|
- `409`:定时日报任务重复(`error === "duplicate"`,响应里会带回已存在的任务),或群聊名称匹配到多个目标(`ambiguous_contact`);
|
||||||
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
|
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
|
||||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||||
- `500`:服务端处理或报告渲染失败。
|
- `500`:服务端处理或报告渲染失败。
|
||||||
@@ -154,12 +173,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`,用来把检索限制在一个确定的语料边界内:
|
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
|
||||||
|
|
||||||
| scope | 含义 |
|
| scope | 含义 |
|
||||||
| ----- | ---- |
|
| ----------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
|
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
|
||||||
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
|
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
|
||||||
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
|
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
|
||||||
| `{"kind":"current","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"`)。
|
`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 +205,11 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
|
|||||||
|
|
||||||
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
|
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
|
||||||
|
|
||||||
| 字段 | 含义 |
|
| 字段 | 含义 |
|
||||||
| ---- | ---- |
|
| ------------------- | ------------------------------------------------------------------- |
|
||||||
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
|
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
|
||||||
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
|
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
|
||||||
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
|
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
|
||||||
| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` |
|
| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` |
|
||||||
|
|
||||||
调用方**必须**把 `coverage` 当真:`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说明"这段范围暂时无法确认",**不能**下"没有找到"的结论。索引落后时服务端会自动请求一次追赶同步,但不会让请求无限等待;`freshness.catchUp` 为 `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 只负责“外部 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 自动监听微信消息。
|
- Agent Hub 状态检查与已连接机器人发送测试。这里的发送接口是开发者/测试用途,不是实时机器人入口,也不会让 Reader Skill 自动监听微信消息。
|
||||||
|
|
||||||
|
注意这个 API 不只是只读的:`/report`、`/agent/send` 和 `/scheduled-reports*` 会写入状态或真的发出微信消息。
|
||||||
|
|
||||||
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
|
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
|
||||||
|
|
||||||
## 隐私边界
|
## 隐私边界
|
||||||
|
|||||||
@@ -4,39 +4,54 @@
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A[本机微信数据] --> B[读取与解析]
|
WX["本机微信数据"] --> PARSE["读取与解析"]
|
||||||
B --> C[聊天档案与普通搜索]
|
|
||||||
B --> D[本地知识索引]
|
PARSE --> ARCHIVE["聊天档案与普通搜索"]
|
||||||
D --> E[筛选相关消息]
|
PARSE --> EXPORT["聊天导出"]
|
||||||
E --> F[用户配置的 AI Provider]
|
|
||||||
F --> G[回答与可核对来源]
|
PARSE --> IDX["本机索引"]
|
||||||
B --> H[聊天导出]
|
IDX --> TEXTIDX["聊天记录索引"]
|
||||||
B --> I[整理日报输入]
|
IDX --> IMGIDX["图片文字索引(本机识别)"]
|
||||||
I --> F
|
|
||||||
F --> J[本地保存 HTML 与 PNG]
|
TEXTIDX --> UNDERSTAND["Understand:AI Search / 问问微信"]
|
||||||
B --> K[Local HTTP API]
|
IMGIDX --> UNDERSTAND
|
||||||
K --> L[外部 Agent]
|
UNDERSTAND --> PROVIDER["你配置的 AI Provider"]
|
||||||
M[微信机器人消息] --> N[Agent Hub]
|
PROVIDER --> ANSWER["回答与可核对来源"]
|
||||||
N --> B
|
|
||||||
N --> F
|
PARSE --> REPORTINPUT["整理日报输入"]
|
||||||
B --> O[Monitor / Snapshot]
|
REPORTINPUT --> PROVIDER
|
||||||
O --> P[Proposed Action]
|
PROVIDER --> REPORTFILE["本机保存 HTML 与 PNG"]
|
||||||
F --> P
|
|
||||||
P --> Q[Policy]
|
PARSE --> MONITOR["Monitor:退群监控 / 成员快照"]
|
||||||
Q --> R[Action Gateway]
|
MONITOR --> RULE["自动化规则"]
|
||||||
R --> S[Personal WeChat Send Capability]
|
REPORTFILE --> RULE
|
||||||
S --> T[Action Audit / Logs]
|
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 的工作方式可以概括为:
|
TraceMemo 的工作方式可以概括为:
|
||||||
|
|
||||||
```text
|
```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
|
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 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
|
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
|
||||||
|
|
||||||
|
这些动作统一由「自动化」管理,当前有三类规则:**@我生成日报**、**定时日报**、**退群通知**。发送目标支持当前群聊、文件传输助手、自己、指定好友,不是任意群发。
|
||||||
|
|
||||||
## 哪些步骤在本机
|
## 哪些步骤在本机
|
||||||
|
|
||||||
- 微信数据库读取与解析;
|
- 微信数据库读取与解析;
|
||||||
- 聊天档案浏览和普通搜索;
|
- 聊天档案浏览和普通搜索;
|
||||||
- Knowledge 索引与增量同步;
|
- Knowledge 索引与增量同步;
|
||||||
|
- 图片文字索引:识别图片中的文字完全在本机进行,原始图片不会因为本地识别而上传;
|
||||||
- 离线语音转写;
|
- 离线语音转写;
|
||||||
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
|
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
|
||||||
|
|
||||||
## 哪些步骤可能调用外部服务
|
## 哪些步骤可能调用外部服务
|
||||||
|
|
||||||
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
|
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库,本机 OCR、离线语音转写和普通搜索也不会触发外发。
|
||||||
|
|
||||||
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
|
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
|
||||||
|
|
||||||
@@ -76,13 +96,15 @@ Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生
|
|||||||
|
|
||||||
## 产品名词和用户任务的对应关系
|
## 产品名词和用户任务的对应关系
|
||||||
|
|
||||||
| 用户想做什么 | 产品中可能看到的名称 |
|
| 用户想做什么 | 产品中可能看到的名称 |
|
||||||
| ------------------------ | ---------------------------- |
|
| ------------------------------ | ---------------------------- |
|
||||||
| 让 AI 找相关聊天 | AI Search、Retrieval |
|
| 让 AI 找相关聊天 | AI Search、Retrieval |
|
||||||
| 让答案能回到原消息 | Evidence、Citation |
|
| 让答案能回到原消息 | Evidence、Citation |
|
||||||
| 查看 AI 查找过程 | Search Trace |
|
| 查看 AI 查找过程 | Search Trace |
|
||||||
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
|
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
|
||||||
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
|
| 搜到截图、公告图里写过的文字 | 图片文字索引、本机 OCR |
|
||||||
| 让微信机器人调用本机能力 | Agent Hub |
|
| 让日报、退群通知按规则自动执行 | 自动化、Policy、执行记录 |
|
||||||
|
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
|
||||||
|
| 让微信机器人调用本机能力 | Agent Hub |
|
||||||
|
|
||||||
先按任务使用,再在需要排查或开发集成时阅读术语。
|
先按任务使用,再在需要排查或开发集成时阅读术语。
|
||||||
|
|||||||
@@ -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/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/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/voice-recognition.ts` | `user-guide/voice.md` |
|
||||||
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
|
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
|
||||||
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
|
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
|
||||||
| `src/main/services/recall-archive-service.ts` | `user-guide/privacy.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/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
|
| `src/main/services/image-insight-service.ts`、AI Provider | `user-guide/report.md`、`user-guide/privacy.md` |
|
||||||
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
|
| `src/shared/automation.ts`、自动化服务与执行网关 | `user-guide/report.md`、`concepts/how-it-works.md`、`docs/README.md` |
|
||||||
| 设置导航、连接页面 | `user-guide/getting-started.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
|
```bash
|
||||||
git diff --check
|
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
|
name: tracememo-reader
|
||||||
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
|
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体,并管理定时日报任务。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结、查询或修改定时日报时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
|
||||||
---
|
---
|
||||||
|
|
||||||
# TraceMemo Reader
|
# TraceMemo Reader
|
||||||
@@ -27,31 +27,39 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
|
|
||||||
## 端点速查
|
## 端点速查
|
||||||
|
|
||||||
| 方法 | 路径 | 用途 |
|
| 方法 | 路径 | 用途 |
|
||||||
| ------ | ----------------------------------- | ------------------------------------------------- |
|
| ------ | ------------------------------------------------------- | ------------------------------------------------- |
|
||||||
| GET | `/health` | 健康和数据库状态 |
|
| GET | `/health` | 健康和数据库状态 |
|
||||||
| GET | `/current_time` | 本机时间与时区 |
|
| GET | `/current_time` | 本机时间与时区 |
|
||||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||||
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
|
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
|
||||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||||
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
|
| POST | `/query/messages` | 按目标与时间范围取消息(结构化,不调用 AI) |
|
||||||
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
|
| POST | `/query/search` | 受限语义关键词检索(依赖本地索引,见 freshness) |
|
||||||
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
|
| POST | `/query/message-context` | 用 `messageRef` 取某条消息的前后文 |
|
||||||
| POST | `/scheduled-reports` | 创建定时日报任务 |
|
| POST | `/query/conversation-overview` | 按会话与时间范围提取可总结的证据 |
|
||||||
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
|
| GET | `/query/capabilities` | Query 端点能力目录 |
|
||||||
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
|
| GET | `/wechat-personal/send-capability` | 个人微信发送能力状态(文字 / 图片 / 语音) |
|
||||||
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
|
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
|
||||||
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
|
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
|
||||||
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
|
| POST | `/scheduled-reports` | 创建定时日报任务 |
|
||||||
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
|
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
|
||||||
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
|
||||||
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
|
||||||
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
|
||||||
| POST | `/agent/send` | 已连接机器人发送测试 |
|
| 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` 返回图片消息时:
|
当 `/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 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
|
||||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。「图片文字索引」不在此列——它在本机识别图片里的文字,不需要配置 AI。
|
||||||
|
|
||||||
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
||||||
|
|
||||||
@@ -134,6 +134,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
|
|||||||
- [生成群聊日报或总结](./report.md)
|
- [生成群聊日报或总结](./report.md)
|
||||||
- [转写微信语音](./voice.md)
|
- [转写微信语音](./voice.md)
|
||||||
- [导出聊天档案](./export.md)
|
- [导出聊天档案](./export.md)
|
||||||
|
- [让日报、退群通知按规则自动运行](../README.md#日报与自动化)
|
||||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||||
|
|
||||||
@@ -159,14 +160,17 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
|
|||||||
|
|
||||||
## 8. 需要配置 AI 吗?
|
## 8. 需要配置 AI 吗?
|
||||||
|
|
||||||
不一定。浏览聊天、普通关键词搜索、建立本地知识库和导出不要求在线 AI 服务。
|
不一定。浏览聊天、普通关键词搜索、建立本地知识库、图片文字识别、离线语音转写和导出都不要求在线 AI 服务。
|
||||||
|
|
||||||
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
|
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
|
||||||
|
|
||||||
|
这两种情况容易混淆:**本机识别图片里的文字**(图片文字索引)不联网、不需要 AI 服务;**让模型看图并回答**(图片理解)才需要配置 AI 服务。
|
||||||
|
|
||||||
## 9. 数据和隐私的最低须知
|
## 9. 数据和隐私的最低须知
|
||||||
|
|
||||||
- 微信数据库、聊天解析和本地索引默认留在本机。
|
- 微信数据库、聊天解析、本地索引和图片文字识别默认留在本机。
|
||||||
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
||||||
|
- 图片文字索引只在本机识别,原始图片不会因为本地识别而上传;识别出的文字会进入本地索引,供搜索和“问问微信”使用。
|
||||||
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
||||||
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
|
- 应用内 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 的前置知识。
|
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
|||||||
- 读取和解析微信数据库;
|
- 读取和解析微信数据库;
|
||||||
- 聊天档案浏览和普通关键词搜索;
|
- 聊天档案浏览和普通关键词搜索;
|
||||||
- 本地 Knowledge 索引及其账号隔离;
|
- 本地 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
|
## Local HTTP API
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,11 @@
|
|||||||
|
|
||||||
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
||||||
|
|
||||||
## 定时日报
|
## 定时日报(在自动化里)
|
||||||
|
|
||||||
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
|
定时日报现在是「自动化」里的一种规则,不再单独占一个页面:打开一级导航的「自动化」,新建或编辑一条「定时日报」规则,选择群聊、执行时间、日报范围、消息类型和发送目标。日报页顶部的指引条也会直接跳到自动化。
|
||||||
|
|
||||||
|
TraceMemo 会按计划执行:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
|
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
|
||||||
@@ -45,6 +47,8 @@
|
|||||||
|
|
||||||
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
|
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 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
|
to: resources
|
||||||
filter:
|
filter:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
- '!connectors/wechat-personal/**'
|
- '!runtime/darwin-arm64/**'
|
||||||
- from: docs/skill/tracememo-reader
|
- from: docs/skill/tracememo-reader
|
||||||
to: skill/tracememo-reader
|
to: skill/tracememo-reader
|
||||||
filter:
|
filter:
|
||||||
|
|||||||
+8
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tracememo",
|
"name": "tracememo",
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"packageManager": "pnpm@7.33.7",
|
"packageManager": "pnpm@7.33.7",
|
||||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
|
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
|
"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: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: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",
|
"start": "electron-vite preview",
|
||||||
"predev": "node scripts/ensure-electron-binary.cjs",
|
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||||
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
"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: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: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": "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",
|
"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": "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",
|
"release:mac": "npm run typecheck && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
|
||||||
@@ -74,8 +75,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@koromix/koffi-darwin-x64": "3.1.0",
|
||||||
"@koromix/koffi-win32-x64": "3.1.0",
|
"@koromix/koffi-win32-x64": "3.1.0",
|
||||||
"@napi-rs/system-ocr": "1.2.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",
|
"@napi-rs/system-ocr-win32-x64-msvc": "1.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
@@ -107,7 +110,9 @@
|
|||||||
"parse5": "^8.0.0",
|
"parse5": "^8.0.0",
|
||||||
"pinyin-pro": "^3.26.0",
|
"pinyin-pro": "^3.26.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sherpa-onnx-darwin-x64": "1.13.3",
|
||||||
"sherpa-onnx-node": "1.13.3",
|
"sherpa-onnx-node": "1.13.3",
|
||||||
|
"sherpa-onnx-win-x64": "1.13.4",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"unzipper": "^0.12.0",
|
"unzipper": "^0.12.0",
|
||||||
@@ -169,4 +174,4 @@
|
|||||||
"ffmpeg-static"
|
"ffmpeg-static"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+38
-31
@@ -11,8 +11,10 @@ specifiers:
|
|||||||
'@electron-toolkit/preload': ^3.0.2
|
'@electron-toolkit/preload': ^3.0.2
|
||||||
'@electron-toolkit/tsconfig': ^2.0.0
|
'@electron-toolkit/tsconfig': ^2.0.0
|
||||||
'@electron-toolkit/utils': ^4.0.0
|
'@electron-toolkit/utils': ^4.0.0
|
||||||
|
'@koromix/koffi-darwin-x64': 3.1.0
|
||||||
'@koromix/koffi-win32-x64': 3.1.0
|
'@koromix/koffi-win32-x64': 3.1.0
|
||||||
'@napi-rs/system-ocr': 1.2.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
|
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
|
||||||
'@playwright/test': ^1.62.1
|
'@playwright/test': ^1.62.1
|
||||||
'@radix-ui/react-alert-dialog': ^1.1.23
|
'@radix-ui/react-alert-dialog': ^1.1.23
|
||||||
@@ -72,7 +74,9 @@ specifiers:
|
|||||||
react: ^19.2.1
|
react: ^19.2.1
|
||||||
react-dom: ^19.2.1
|
react-dom: ^19.2.1
|
||||||
sass: ^1.102.0
|
sass: ^1.102.0
|
||||||
|
sherpa-onnx-darwin-x64: 1.13.3
|
||||||
sherpa-onnx-node: 1.13.3
|
sherpa-onnx-node: 1.13.3
|
||||||
|
sherpa-onnx-win-x64: 1.13.4
|
||||||
silk-wasm: ^3.7.1
|
silk-wasm: ^3.7.1
|
||||||
tailwind-merge: ^3.6.0
|
tailwind-merge: ^3.6.0
|
||||||
tailwindcss: 3.4.17
|
tailwindcss: 3.4.17
|
||||||
@@ -87,8 +91,10 @@ specifiers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||||
'@electron-toolkit/utils': 4.0.0_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
|
'@koromix/koffi-win32-x64': 3.1.0
|
||||||
'@napi-rs/system-ocr': 1.2.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
|
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
|
||||||
'@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
|
'@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
|
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
@@ -120,7 +126,9 @@ dependencies:
|
|||||||
parse5: 8.0.1
|
parse5: 8.0.1
|
||||||
pinyin-pro: 3.29.3
|
pinyin-pro: 3.29.3
|
||||||
qrcode: 1.5.4
|
qrcode: 1.5.4
|
||||||
|
sherpa-onnx-darwin-x64: 1.13.3
|
||||||
sherpa-onnx-node: 1.13.3
|
sherpa-onnx-node: 1.13.3
|
||||||
|
sherpa-onnx-win-x64: 1.13.4
|
||||||
silk-wasm: 3.7.1
|
silk-wasm: 3.7.1
|
||||||
tailwind-merge: 3.6.0
|
tailwind-merge: 3.6.0
|
||||||
unzipper: 0.12.5
|
unzipper: 0.12.5
|
||||||
@@ -1585,7 +1593,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
|
||||||
|
|
||||||
/@koromix/koffi-freebsd-arm64/3.1.0:
|
/@koromix/koffi-freebsd-arm64/3.1.0:
|
||||||
resolution: {integrity: sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==}
|
resolution: {integrity: sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==}
|
||||||
@@ -1689,6 +1696,36 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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:
|
/@napi-rs/system-ocr/1.2.0:
|
||||||
resolution: {integrity: sha512-r0f2xNH6U+sth44qF+lUP+2WuHSGUBAry5KSCNuaLDGRbgslFqeROr/qJJ/fb6AjBp3Ov+CJP5MdrOWoaoM3cw==}
|
resolution: {integrity: sha512-r0f2xNH6U+sth44qF+lUP+2WuHSGUBAry5KSCNuaLDGRbgslFqeROr/qJJ/fb6AjBp3Ov+CJP5MdrOWoaoM3cw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -1699,34 +1736,6 @@ packages:
|
|||||||
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
|
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
|
||||||
dev: false
|
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:
|
/@nodelib/fs.scandir/2.1.5:
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -7589,7 +7598,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
|
||||||
|
|
||||||
/sherpa-onnx-linux-arm64/1.13.4:
|
/sherpa-onnx-linux-arm64/1.13.4:
|
||||||
resolution: {integrity: sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw==}
|
resolution: {integrity: sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw==}
|
||||||
@@ -7628,7 +7636,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
|
||||||
|
|
||||||
/side-channel-list/1.0.0:
|
/side-channel-list/1.0.0:
|
||||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 155 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
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) {
|
function normalizeBuilderArch(arch) {
|
||||||
if (typeof arch === 'string') return arch
|
if (typeof arch === 'string') return arch
|
||||||
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCodesign(args) {
|
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) {
|
function isMacosCodeValid(targetPath, run = runCodesign) {
|
||||||
@@ -174,8 +223,89 @@ function signMacosHelpers(runtimeResources, run = runCodesign) {
|
|||||||
return helperPaths
|
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) {
|
function signMacosAppBundle(appBundlePath, run = runCodesign) {
|
||||||
if (isMacosCodeValid(appBundlePath, run)) return appBundlePath
|
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])
|
run(['--force', '--sign', '-', appBundlePath])
|
||||||
try {
|
try {
|
||||||
run(['--verify', '--strict', appBundlePath])
|
run(['--verify', '--strict', appBundlePath])
|
||||||
@@ -325,9 +455,70 @@ function pruneForeignArchConnectors(runtimeResources, platform, arch) {
|
|||||||
return removed
|
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) {
|
exports.default = async function afterPack(context) {
|
||||||
const runtimeResources = getRuntimeResources(context)
|
const runtimeResources = getRuntimeResources(context)
|
||||||
const arch = normalizeBuilderArch(context.arch)
|
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)
|
validateAsarRuntimeDependencies(runtimeResources)
|
||||||
validateReaderSkillRuntime(runtimeResources)
|
validateReaderSkillRuntime(runtimeResources)
|
||||||
validateSilkWasmRuntime(runtimeResources)
|
validateSilkWasmRuntime(runtimeResources)
|
||||||
@@ -340,6 +531,7 @@ exports.default = async function afterPack(context) {
|
|||||||
)
|
)
|
||||||
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
|
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||||
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
|
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||||
|
validateKoffiRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||||
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
|
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
|
||||||
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
|
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
|
||||||
pruneForeignArchNativeRuntimes(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
|
const productName = context.packager.appInfo.productFilename
|
||||||
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
|
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
|
exports.getRuntimeResources = getRuntimeResources
|
||||||
@@ -378,6 +553,7 @@ exports.validateFfmpegRuntime = validateFfmpegRuntime
|
|||||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||||
exports.validateSherpaRuntime = validateSherpaRuntime
|
exports.validateSherpaRuntime = validateSherpaRuntime
|
||||||
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
|
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
|
||||||
|
exports.validateKoffiRuntime = validateKoffiRuntime
|
||||||
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
|
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
|
||||||
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
|
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
|
||||||
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
|
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
|
||||||
@@ -386,3 +562,7 @@ exports.findMacosHelperPaths = findMacosHelperPaths
|
|||||||
exports.isMacosCodeValid = isMacosCodeValid
|
exports.isMacosCodeValid = isMacosCodeValid
|
||||||
exports.signMacosHelpers = signMacosHelpers
|
exports.signMacosHelpers = signMacosHelpers
|
||||||
exports.signMacosAppBundle = signMacosAppBundle
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1172,37 +1172,13 @@ const renderExportScript = (name: string): string => `
|
|||||||
}
|
}
|
||||||
const renderPaymentContent = (data, kind) => {
|
const renderPaymentContent = (data, kind) => {
|
||||||
const isTransfer = kind === 'transfer'
|
const isTransfer = kind === 'transfer'
|
||||||
const pay = (isTransfer ? data.transfer : data.pay) || {}
|
|
||||||
const amount = pay.amountText
|
|
||||||
const memo = pay.payMemo
|
|
||||||
const statusText = isTransfer ? pay.transferStatusText : pay.redPacketStatusText
|
|
||||||
const meta = [amount, memo ? '备注:' + memo : '', statusText]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')
|
|
||||||
const title = isTransfer
|
|
||||||
? (data.title || '微信转账')
|
|
||||||
: (pay.sendTitle || pay.receiveTitle || data.title || '微信红包')
|
|
||||||
const blurb =
|
|
||||||
data.description ||
|
|
||||||
data.des ||
|
|
||||||
(isTransfer ? '转账消息' : (pay.sceneText || '恭喜发财,大吉大利'))
|
|
||||||
// 状态/金额/备注始终单独露出,避免被消息原文 des 盖住。
|
|
||||||
const description = meta
|
|
||||||
? (blurb && blurb !== meta ? blurb + ' · ' + meta : meta)
|
|
||||||
: blurb
|
|
||||||
const footerBits = isTransfer
|
|
||||||
? [pay.paySubtype === '3' ? '收款' : pay.paySubtype === '1' || pay.paySubtype === '4' ? '转账' : '',
|
|
||||||
pay.transferId ? '单号 ' + pay.transferId : '']
|
|
||||||
: [pay.hbType ? '类型 ' + pay.hbType : '', pay.sendId ? 'sendid ' + pay.sendId : '']
|
|
||||||
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
|
return '<div class="structured-content payment-content ' + (isTransfer ? 'transfer' : 'red-packet') +
|
||||||
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
|
'" data-rich-kind="' + (isTransfer ? 'transfer' : 'redPacket') + '">' +
|
||||||
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
|
'<div class="structured-kicker">' + (isTransfer ? '微信转账' : '微信红包') + '</div>' +
|
||||||
'<div class="structured-title">' + displayText(title) + '</div>' +
|
'<div class="structured-title">' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '</div>' +
|
||||||
'<div class="structured-description">' + displayText(description) + '</div>' +
|
'<div class="structured-description">' +
|
||||||
(footerBits.filter(Boolean).length
|
displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) +
|
||||||
? '<div class="structured-footer">' + displayText(footerBits.filter(Boolean).join(' · ')) + '</div>'
|
'</div></div>'
|
||||||
: '') +
|
|
||||||
'</div>'
|
|
||||||
}
|
}
|
||||||
const renderVoipContent = (data) => {
|
const renderVoipContent = (data) => {
|
||||||
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
|
const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话'
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
|
|||||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||||
import { imageFileQuality } from '../shared/image-quality'
|
import { imageFileQuality } from '../shared/image-quality'
|
||||||
import { resolveMemberName } from '../shared/member-names'
|
import { resolveMemberName } from '../shared/member-names'
|
||||||
import { FavoritesService } from './favorites-service'
|
|
||||||
import { filesystemSafeName } from '../shared/contact-name'
|
import { filesystemSafeName } from '../shared/contact-name'
|
||||||
|
|
||||||
const jobs = new Set<string>()
|
const jobs = new Set<string>()
|
||||||
@@ -854,28 +853,6 @@ async function runSingleExport(
|
|||||||
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
|
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (request.includeFavorites) {
|
|
||||||
const wcdb = chat.getChatDb()?.getWcdb4Client()
|
|
||||||
if (wcdb) {
|
|
||||||
try {
|
|
||||||
const favoriteMessages = await new FavoritesService(wcdb).listExportMessages(500)
|
|
||||||
for (const [messageOrder, message] of favoriteMessages.entries()) {
|
|
||||||
if (!request.kinds.includes(kindOf(message))) continue
|
|
||||||
messageEntries.push({
|
|
||||||
message: {
|
|
||||||
...message,
|
|
||||||
exportConversationId: 'favorites',
|
|
||||||
exportConversationName: '收藏'
|
|
||||||
},
|
|
||||||
targetOrder: targets.length,
|
|
||||||
messageOrder
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[export] favorites merge skipped:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const messages = messageEntries
|
const messages = messageEntries
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { Message, ParsedContent } from '../shared/types'
|
|
||||||
import { favoriteRowToContent } from '../shared/favorites'
|
|
||||||
import type { Wcdb4Client } from './wcdb4-client'
|
|
||||||
|
|
||||||
/** 收藏只读服务:favorite.db → 可读卡片 / 导出消息。 */
|
|
||||||
export class FavoritesService {
|
|
||||||
constructor(private readonly wcdb4Client: Wcdb4Client) {}
|
|
||||||
|
|
||||||
async listContents(limit = 200): Promise<ParsedContent[]> {
|
|
||||||
const rows = await this.wcdb4Client.listFavoriteItems(limit)
|
|
||||||
return rows.map((row) => favoriteRowToContent(row))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 收藏转成导出/搜索可用的 Message 列表(合成会话「收藏」)。 */
|
|
||||||
async listExportMessages(limit = 200): Promise<Message[]> {
|
|
||||||
const rows = await this.wcdb4Client.listFavoriteItems(limit)
|
|
||||||
return rows.map((row, index) => {
|
|
||||||
const contentData = favoriteRowToContent(row)
|
|
||||||
const createTime = Number(row.update_time) || 0
|
|
||||||
return {
|
|
||||||
id: `fav-${row.local_id ?? index}`,
|
|
||||||
from: 'favorite',
|
|
||||||
type: '收藏',
|
|
||||||
datetime: createTime ? new Date(createTime * 1000).toISOString() : '',
|
|
||||||
content: textOf(contentData),
|
|
||||||
isSender: false,
|
|
||||||
createTime,
|
|
||||||
name: '收藏',
|
|
||||||
contentData
|
|
||||||
} as Message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 只读关键词搜索收藏文本(title/desc/正文)。 */
|
|
||||||
async search(query: string, limit = 50): Promise<ParsedContent[]> {
|
|
||||||
const needle = String(query || '').trim().toLowerCase()
|
|
||||||
if (!needle) return []
|
|
||||||
const items = await this.listContents(500)
|
|
||||||
return items.filter((item) => textOf(item).toLowerCase().includes(needle)).slice(0, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local Query API 适配:只要文本与时间戳。 */
|
|
||||||
async searchHits(
|
|
||||||
query: string,
|
|
||||||
limit = 20
|
|
||||||
): Promise<Array<{ text: string; timestamp?: number }>> {
|
|
||||||
const rows = await this.wcdb4Client.listFavoriteItems(500)
|
|
||||||
const needle = String(query || '').trim().toLowerCase()
|
|
||||||
if (!needle) return []
|
|
||||||
return rows
|
|
||||||
.map((row) => {
|
|
||||||
const content = favoriteRowToContent(row)
|
|
||||||
return {
|
|
||||||
text: textOf(content),
|
|
||||||
timestamp: Number(row.update_time) || undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((hit) => hit.text.toLowerCase().includes(needle))
|
|
||||||
.slice(0, limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function textOf(content: ParsedContent): string {
|
|
||||||
if (content.type === 'text') return content.content
|
|
||||||
if (content.type === 'system') return content.content
|
|
||||||
if (content.type === 'share') return [content.title, content.des].filter(Boolean).join(' · ')
|
|
||||||
if (content.type === 'location') return content.poiname || content.label || '[位置]'
|
|
||||||
if (content.type === 'miniProgram') return content.title || '[小程序]'
|
|
||||||
if (content.type === 'forwardBundle') return content.title || '[聊天记录]'
|
|
||||||
return '[收藏]'
|
|
||||||
}
|
|
||||||
+10
-4
@@ -3,6 +3,7 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
|||||||
import {
|
import {
|
||||||
isReady,
|
isReady,
|
||||||
listContacts,
|
listContacts,
|
||||||
|
listContactsAsync,
|
||||||
listMessages,
|
listMessages,
|
||||||
getGroupSnapshot,
|
getGroupSnapshot,
|
||||||
listRecentChat,
|
listRecentChat,
|
||||||
@@ -230,21 +231,26 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/contact': ({ res, url }) => {
|
'/api/v1/contact': async ({ res, url }) => {
|
||||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||||
const filter = url.searchParams.get('filter') || undefined
|
const filter = url.searchParams.get('filter') || undefined
|
||||||
const type = url.searchParams.get('type') || 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') {
|
if (type === 'user' || type === 'group') {
|
||||||
contacts = contacts.filter((c) => c.type === type)
|
contacts = contacts.filter((c) => c.type === type)
|
||||||
}
|
}
|
||||||
sendJson(res, 200, { count: contacts.length, contacts })
|
sendJson(res, 200, { count: contacts.length, contacts })
|
||||||
},
|
},
|
||||||
|
|
||||||
'/api/v1/chatroom': ({ res, url }) => {
|
'/api/v1/chatroom': async ({ res, url }) => {
|
||||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||||
const keyword = url.searchParams.get('keyword') || ''
|
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) {
|
if (keyword) {
|
||||||
const lower = keyword.toLowerCase()
|
const lower = keyword.toLowerCase()
|
||||||
groups = groups.filter(
|
groups = groups.filter(
|
||||||
|
|||||||
+413
-121
@@ -14,7 +14,8 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Tray,
|
Tray,
|
||||||
dialog,
|
dialog,
|
||||||
protocol
|
protocol,
|
||||||
|
screen
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { dirname, extname, join } from 'path'
|
import { dirname, extname, join } from 'path'
|
||||||
import { existsSync, promises as fsPromises } from 'fs'
|
import { existsSync, promises as fsPromises } from 'fs'
|
||||||
@@ -112,22 +113,28 @@ import { agentHubService } from './services/agent-hub-service'
|
|||||||
import { WechatConnectorService } from './services/wechat-ilink'
|
import { WechatConnectorService } from './services/wechat-ilink'
|
||||||
import { wechatSendGateway } from './services/wechat-send-gateway'
|
import { wechatSendGateway } from './services/wechat-send-gateway'
|
||||||
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
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 { 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 { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||||
|
import { macWechatRuntimeManager } from './services/mac-wechat-runtime-manager'
|
||||||
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
||||||
import { scheduledReportService } from './services/scheduled-report-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 { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatGeneratedTtsVoiceRequest,
|
PersonalWechatGeneratedTtsVoiceRequest,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult
|
PersonalWechatSendResult,
|
||||||
|
PersonalWechatSenderStatus
|
||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type {
|
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
|
||||||
import { isTruthyDebugFlag } from '../shared/debug-flags'
|
import { isTruthyDebugFlag } from '../shared/debug-flags'
|
||||||
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
||||||
import type {
|
import type {
|
||||||
@@ -196,6 +203,12 @@ import type {
|
|||||||
WechatShareServiceConfig
|
WechatShareServiceConfig
|
||||||
} from '../shared/wechat-share-card'
|
} 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.
|
// 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
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
// handler. Wrap console.* before any other module logs anything.
|
// handler. Wrap console.* before any other module logs anything.
|
||||||
@@ -211,6 +224,7 @@ let voiceService: VoiceService | null = null
|
|||||||
let voiceRecognition: VoiceRecognitionUseCase | null = null
|
let voiceRecognition: VoiceRecognitionUseCase | null = null
|
||||||
let voiceBatchService: VoiceBatchService | null = null
|
let voiceBatchService: VoiceBatchService | null = null
|
||||||
let knowledgeSearchService: KnowledgeSearchService | null = null
|
let knowledgeSearchService: KnowledgeSearchService | null = null
|
||||||
|
let groupStatsService: GroupStatsService | null = null
|
||||||
let localQueryApiService: LocalQueryApiService | null = null
|
let localQueryApiService: LocalQueryApiService | null = null
|
||||||
let aiSearchPipelineService: AiSearchPipelineService | null = null
|
let aiSearchPipelineService: AiSearchPipelineService | null = null
|
||||||
let queryAgentService: QueryAgentService | null = null
|
let queryAgentService: QueryAgentService | null = null
|
||||||
@@ -222,7 +236,6 @@ const databaseKeyStore = new DatabaseKeyStore()
|
|||||||
const imageKeyConfigService = new ImageKeyConfigService()
|
const imageKeyConfigService = new ImageKeyConfigService()
|
||||||
const aiProviderService = new AIProviderService()
|
const aiProviderService = new AIProviderService()
|
||||||
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
const textToSpeechSettingsService = new TextToSpeechSettingsService()
|
||||||
const personalWechatRuntimeManager = new PersonalWechatRuntimeManager()
|
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
const wechatShareConfigStore = new WechatShareConfigStore()
|
const wechatShareConfigStore = new WechatShareConfigStore()
|
||||||
@@ -492,9 +505,29 @@ async function createLocalMediaResponse(request: Request, filePath: string): Pro
|
|||||||
|
|
||||||
function createWindow(): void {
|
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({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: initialWidth,
|
||||||
height: 800,
|
height: initialHeight,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 640,
|
||||||
|
center: true,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
icon: appIconPath,
|
icon: appIconPath,
|
||||||
@@ -754,6 +787,9 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
|
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
|
||||||
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
|
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
|
||||||
|
// 群员统计复用同一个 Knowledge 实例:它只是「读派生库 + 读成员名单」的编排,
|
||||||
|
// 不持有自己的数据库,也不新建索引。
|
||||||
|
groupStatsService = new GroupStatsService(knowledgeSearchService)
|
||||||
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
|
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
|
||||||
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
|
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
|
||||||
localQueryApiService.setImageTextCoverageProvider(() =>
|
localQueryApiService.setImageTextCoverageProvider(() =>
|
||||||
@@ -771,13 +807,6 @@ app.whenReady().then(async () => {
|
|||||||
localQueryApiService.setImageOcrEntryProvider((conversationId, messageId) =>
|
localQueryApiService.setImageOcrEntryProvider((conversationId, messageId) =>
|
||||||
imageTextIndexService.getConversationOcr(conversationId).get(messageId)
|
imageTextIndexService.getConversationOcr(conversationId).get(messageId)
|
||||||
)
|
)
|
||||||
// 收藏只读检索:search_messages 合并 favorite.db 命中(无库时返回空)。
|
|
||||||
localQueryApiService.setFavoritesSearchProvider(async (query, limit) => {
|
|
||||||
const wcdb = chat.getChatDb()?.getWcdb4Client()
|
|
||||||
if (!wcdb) return []
|
|
||||||
const { FavoritesService } = await import('./favorites-service')
|
|
||||||
return new FavoritesService(wcdb).searchHits(query, limit)
|
|
||||||
})
|
|
||||||
setLocalQueryApiService(localQueryApiService)
|
setLocalQueryApiService(localQueryApiService)
|
||||||
// Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。
|
// Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。
|
||||||
queryAgentService = new QueryAgentService(
|
queryAgentService = new QueryAgentService(
|
||||||
@@ -837,11 +866,6 @@ app.whenReady().then(async () => {
|
|||||||
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
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) => {
|
protocol.handle('wxe-media', async (request) => {
|
||||||
const filePath = videoAssetService?.pathForUrl(request.url)
|
const filePath = videoAssetService?.pathForUrl(request.url)
|
||||||
if (!filePath) return new Response('Not found', { status: 404 })
|
if (!filePath) return new Response('Not found', { status: 404 })
|
||||||
@@ -1004,20 +1028,120 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||||
}
|
}
|
||||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
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 })
|
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
||||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||||
voiceService = new VoiceService(wcdb4Client, resolvedRoot)
|
voiceService = new VoiceService(wcdb4Client, resolvedRoot)
|
||||||
voiceRecognition?.connect(voiceService, resolvedRoot)
|
voiceRecognition?.connect(voiceService, resolvedRoot)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||||
const monitoring = await wcdb4Client.startMonitor((type, json) => {
|
const monitoring = await wcdb4Client.startMonitor((event) => {
|
||||||
wcdb4Client.invalidateSessionCache()
|
wcdb4Client.invalidateSessionCache()
|
||||||
groupExitMonitorService.notifyDatabaseChanged(json)
|
// 正式 MessageListener:只做 coalesce + 有界回读 + dedup + 投递,不触发业务。
|
||||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
// v2 事件带 sessionId ⇒ 回读不再依赖「最近活跃会话」。
|
||||||
|
messageListener.handleNativeChange(event)
|
||||||
|
groupExitMonitorService.notifyDatabaseChanged(event.raw)
|
||||||
|
recallArchiveMonitor?.handleDatabaseChange(event.raw)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
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)
|
void groupExitMonitorService.start(monitoring)
|
||||||
const recentSession = sessions[0]
|
const recentSession = sessions[0]
|
||||||
if (recentSession?.username) {
|
if (recentSession?.username) {
|
||||||
@@ -1159,7 +1283,54 @@ app.whenReady().then(async () => {
|
|||||||
)
|
)
|
||||||
// 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。
|
// 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。
|
||||||
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
|
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)
|
return personalWechatSendService.send(request)
|
||||||
}
|
}
|
||||||
let fromId = ''
|
let fromId = ''
|
||||||
@@ -1476,31 +1647,153 @@ app.whenReady().then(async () => {
|
|||||||
return snapshot
|
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:getState', () => groupExitMonitorService.getState())
|
||||||
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
|
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
|
||||||
groupExitMonitorService.setEnabled(enabled === true)
|
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:setGroups', (_, roomIds: string[]) =>
|
||||||
)
|
groupExitMonitorService.setMonitoredRoomIds(Array.isArray(roomIds) ? roomIds : [])
|
||||||
ipcMain.handle('group-exit-monitor:setTemplate', (_, template: unknown) =>
|
|
||||||
groupExitMonitorService.setNotificationTemplate(template)
|
|
||||||
)
|
)
|
||||||
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
|
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:clearEvents', () => groupExitMonitorService.clearEvents())
|
||||||
ipcMain.handle('group-exit-monitor:resendEvent', (_, eventId: string) =>
|
|
||||||
groupExitMonitorService.resendEvent(eventId)
|
|
||||||
)
|
|
||||||
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
|
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
|
||||||
groupExitMonitorService.markRead(readAt)
|
groupExitMonitorService.markRead(readAt)
|
||||||
)
|
)
|
||||||
ipcMain.handle('wechat-action-log:list', () => wechatActionLogService.list())
|
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('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'knowledge:search',
|
'knowledge:search',
|
||||||
@@ -1720,44 +2013,58 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
|
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()
|
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))
|
scheduledReportService.setNotificationEnabled(Boolean(enabled))
|
||||||
)
|
)
|
||||||
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
|
ipcMain.handle('automation:testScheduledReportErrorNotification', (_, ruleId: string) => {
|
||||||
scheduledReportService.createTask(request)
|
|
||||||
)
|
|
||||||
ipcMain.handle(
|
|
||||||
'scheduled-report:update',
|
|
||||||
(_, taskId: string, request: ScheduledReportUpdateInput) =>
|
|
||||||
scheduledReportService.updateTask(taskId, request)
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:delete', (_, taskId: string) =>
|
|
||||||
scheduledReportService.deleteTask(taskId)
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:setEnabled', (_, taskId: string, enabled: boolean) =>
|
|
||||||
scheduledReportService.setTaskEnabled(taskId, Boolean(enabled))
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
|
|
||||||
scheduledReportService.runScheduledReportNow(taskId)
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:retrySend', (_, executionId: string) =>
|
|
||||||
scheduledReportService.retryScheduledReportSend(executionId)
|
|
||||||
)
|
|
||||||
ipcMain.handle('scheduled-report:testErrorNotification', (_, taskId: string) => {
|
|
||||||
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
|
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
success: false,
|
success: false,
|
||||||
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
|
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return scheduledReportService.testScheduledReportErrorNotification(taskId)
|
return scheduledReportService.testScheduledReportErrorNotification(ruleId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||||
@@ -2223,11 +2530,13 @@ app.whenReady().then(async () => {
|
|||||||
if (client) {
|
if (client) {
|
||||||
voiceService = new VoiceService(client, client.getAccountRoot())
|
voiceService = new VoiceService(client, client.getAccountRoot())
|
||||||
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
||||||
const monitoring = await client.startMonitor((type, json) => {
|
const monitoring = await client.startMonitor((event) => {
|
||||||
client.invalidateSessionCache()
|
client.invalidateSessionCache()
|
||||||
groupExitMonitorService.notifyDatabaseChanged(json)
|
groupExitMonitorService.notifyDatabaseChanged(event.raw)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
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)
|
void groupExitMonitorService.start(monitoring)
|
||||||
@@ -2352,42 +2661,16 @@ app.whenReady().then(async () => {
|
|||||||
agentHubService.clearConversations()
|
agentHubService.clearConversations()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus())
|
ipcMain.handle('wechat-personal:getStatus', () => currentPersonalWechatSenderStatus())
|
||||||
ipcMain.handle('wechat-personal:getKeepProcess', () =>
|
|
||||||
personalWechatSendService.getKeepOneBotProcess()
|
|
||||||
)
|
|
||||||
ipcMain.handle('wechat-personal:setKeepProcess', (_, keep: boolean) =>
|
|
||||||
personalWechatSendService.setKeepOneBotProcess(Boolean(keep))
|
|
||||||
)
|
|
||||||
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
|
ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) =>
|
||||||
personalWechatSendService.checkWindowsStatus(port)
|
personalWechatSendService.checkWindowsStatus(port)
|
||||||
)
|
)
|
||||||
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
|
ipcMain.handle('wechat-personal:checkVoiceEnvironment', () =>
|
||||||
personalWechatVoiceEnvironmentService.check()
|
personalWechatVoiceEnvironmentService.check()
|
||||||
)
|
)
|
||||||
ipcMain.handle('wechat-personal:installPilk', async () => {
|
ipcMain.handle('wechat-personal:installPilk', () =>
|
||||||
const result = await personalWechatVoiceEnvironmentService.installPilk()
|
personalWechatVoiceEnvironmentService.installPilk()
|
||||||
if (!result.success || process.platform !== 'darwin') return result
|
)
|
||||||
|
|
||||||
const senderStatus = await personalWechatSendService.getStatus()
|
|
||||||
if (!senderStatus.oneBotPid) return result
|
|
||||||
try {
|
|
||||||
const restartedStatus = await personalWechatSendService.restartRuntime()
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
restarted: restartedStatus.state !== 'error',
|
|
||||||
...(restartedStatus.state === 'error'
|
|
||||||
? { restartError: restartedStatus.error || restartedStatus.message }
|
|
||||||
: {})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
restarted: false,
|
|
||||||
restartError: error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => {
|
ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal('https://www.python.org/downloads/macos/')
|
await shell.openExternal('https://www.python.org/downloads/macos/')
|
||||||
@@ -2404,23 +2687,20 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '无法打开 FFmpeg 安装页面' }
|
return { success: false, error: '无法打开 FFmpeg 安装页面' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus())
|
ipcMain.handle('wechat-personal:rebind', async () => {
|
||||||
ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download())
|
if (process.platform !== 'darwin') return personalWechatSendService.rebind()
|
||||||
ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({
|
|
||||||
success: personalWechatRuntimeManager.cancelDownload()
|
const result = await macWechatRuntimeManager.bind()
|
||||||
}))
|
const status = await macWechatRuntimeManager.buildSenderStatus()
|
||||||
ipcMain.handle('wechat-personal:removeRuntime', async () => {
|
if (result.ok) return status
|
||||||
await personalWechatSendService.terminate(true)
|
|
||||||
return personalWechatRuntimeManager.remove()
|
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(
|
ipcMain.handle(
|
||||||
'wechat-personal:sendGeneratedTtsVoice',
|
'wechat-personal:sendGeneratedTtsVoice',
|
||||||
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
|
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
|
||||||
@@ -2448,7 +2728,7 @@ app.whenReady().then(async () => {
|
|||||||
action.sendResult && typeof action.sendResult === 'object'
|
action.sendResult && typeof action.sendResult === 'object'
|
||||||
? (action.sendResult as PersonalWechatSendResult)
|
? (action.sendResult as PersonalWechatSendResult)
|
||||||
: undefined
|
: undefined
|
||||||
const status = sendResult?.status || (await personalWechatSendService.getStatus())
|
const status = sendResult?.status || (await currentPersonalWechatSenderStatus())
|
||||||
return { action, status }
|
return { action, status }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2523,9 +2803,21 @@ app.on('before-quit', (event) => {
|
|||||||
chat.closeChatDbForQuit().catch(() => false),
|
chat.closeChatDbForQuit().catch(() => false),
|
||||||
voiceRecognition?.dispose().catch(() => undefined),
|
voiceRecognition?.dispose().catch(() => undefined),
|
||||||
knowledgeSearchService?.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) {
|
if (!nativeCallsDrained) {
|
||||||
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
KnowledgeImageOcrState,
|
KnowledgeImageOcrState,
|
||||||
KnowledgeAttachmentMetadata,
|
KnowledgeAttachmentMetadata,
|
||||||
KnowledgeEvidence,
|
KnowledgeEvidence,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeMessageKind,
|
KnowledgeMessageKind,
|
||||||
KnowledgePassProgress,
|
KnowledgePassProgress,
|
||||||
KnowledgeRuntimeState,
|
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` 组成。
|
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
KnowledgeIndexProgress,
|
KnowledgeIndexProgress,
|
||||||
KnowledgeIndexRequest,
|
KnowledgeIndexRequest,
|
||||||
KnowledgeIndexResult,
|
KnowledgeIndexResult,
|
||||||
|
KnowledgeMemberStatsRequest,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeSearchRequest,
|
KnowledgeSearchRequest,
|
||||||
KnowledgeSearchResult,
|
KnowledgeSearchResult,
|
||||||
@@ -53,6 +55,13 @@ export class KnowledgeService {
|
|||||||
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
|
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> {
|
cancelIndex(): Promise<boolean> {
|
||||||
return this.worker.cancelActiveIndex()
|
return this.worker.cancelActiveIndex()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
KnowledgeIndexProgress,
|
KnowledgeIndexProgress,
|
||||||
KnowledgeIndexRequest,
|
KnowledgeIndexRequest,
|
||||||
KnowledgeIndexResult,
|
KnowledgeIndexResult,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeNormalizedMessage,
|
KnowledgeNormalizedMessage,
|
||||||
KnowledgeQuery,
|
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)——「索引更新到哪」的权威口径。
|
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
KnowledgeIndexProgress,
|
KnowledgeIndexProgress,
|
||||||
KnowledgeIndexRequest,
|
KnowledgeIndexRequest,
|
||||||
KnowledgeIndexResult,
|
KnowledgeIndexResult,
|
||||||
|
KnowledgeMemberStatsRequest,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeSearchRequest,
|
KnowledgeSearchRequest,
|
||||||
KnowledgeSearchResult,
|
KnowledgeSearchResult,
|
||||||
@@ -19,6 +21,7 @@ type WorkerResult =
|
|||||||
| KnowledgeCapacityPreflight
|
| KnowledgeCapacityPreflight
|
||||||
| KnowledgeSearchResult
|
| KnowledgeSearchResult
|
||||||
| KnowledgeRuntimeStatus
|
| KnowledgeRuntimeStatus
|
||||||
|
| KnowledgeMemberStatsResult
|
||||||
| { marks: Record<string, number> }
|
| { marks: Record<string, number> }
|
||||||
| { removed: true }
|
| { removed: true }
|
||||||
type PendingRequest = {
|
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> {
|
async cancelActiveIndex(): Promise<boolean> {
|
||||||
const target = this.activeIndexRequestId
|
const target = this.activeIndexRequestId
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
KnowledgeCapacityPreflightRequest,
|
KnowledgeCapacityPreflightRequest,
|
||||||
KnowledgeIndexRequest,
|
KnowledgeIndexRequest,
|
||||||
|
KnowledgeMemberStatsRequest,
|
||||||
|
KnowledgeMemberStatsResult,
|
||||||
KnowledgeRuntimeStatus,
|
KnowledgeRuntimeStatus,
|
||||||
KnowledgeSearchRequest,
|
KnowledgeSearchRequest,
|
||||||
KnowledgeStatusRequest,
|
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> {
|
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (request.type === 'cancel') {
|
if (request.type === 'cancel') {
|
||||||
@@ -226,6 +260,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
|
|||||||
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (request.type === 'memberStats') {
|
||||||
|
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (request.type === 'index') {
|
if (request.type === 'index') {
|
||||||
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
+9
-482
@@ -1,5 +1,3 @@
|
|||||||
import { describeRedPacketStatus, describeTransferStatus } from '../shared/payment-status'
|
|
||||||
|
|
||||||
type TextContent = { type: 'text'; content: string }
|
type TextContent = { type: 'text'; content: string }
|
||||||
type VoiceContent = { type: 'voice'; duration?: number }
|
type VoiceContent = { type: 'voice'; duration?: number }
|
||||||
type LocationContent = {
|
type LocationContent = {
|
||||||
@@ -24,44 +22,6 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
articles?: ShareArticle[]
|
articles?: ShareArticle[]
|
||||||
transfer?: TransferPaymentInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
type TransferPaymentInfo = {
|
|
||||||
paySubtype?: string
|
|
||||||
amountText?: string
|
|
||||||
transcationId?: string
|
|
||||||
transferId?: string
|
|
||||||
invalidTime?: string
|
|
||||||
beginTransferTime?: string
|
|
||||||
effectiveDate?: string
|
|
||||||
payMemo?: string
|
|
||||||
receiverUsername?: string
|
|
||||||
payerUsername?: string
|
|
||||||
transferStatus?: string
|
|
||||||
/** `transfer_status` 展示文案(只读)。 */
|
|
||||||
transferStatusText?: string
|
|
||||||
transId?: string
|
|
||||||
feeType?: string
|
|
||||||
transferAttach?: string
|
|
||||||
refundBankType?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type RedPacketPaymentInfo = {
|
|
||||||
templateId?: string
|
|
||||||
receiveTitle?: string
|
|
||||||
sendTitle?: string
|
|
||||||
sceneText?: string
|
|
||||||
senderDes?: string
|
|
||||||
receiverDes?: string
|
|
||||||
iconUrl?: string
|
|
||||||
nativeUrl?: string
|
|
||||||
sendId?: string
|
|
||||||
hbType?: string
|
|
||||||
hbStatus?: string
|
|
||||||
receiveStatus?: string
|
|
||||||
/** `hb_status` / `receive_status` 展示文案(只读)。 */
|
|
||||||
redPacketStatusText?: string
|
|
||||||
}
|
}
|
||||||
type ForwardedMessageItem = {
|
type ForwardedMessageItem = {
|
||||||
messageType: number
|
messageType: number
|
||||||
@@ -91,7 +51,6 @@ type RedPacketContent = {
|
|||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
url?: string
|
url?: string
|
||||||
pay?: RedPacketPaymentInfo
|
|
||||||
}
|
}
|
||||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
type ImageContent = {
|
type ImageContent = {
|
||||||
@@ -163,26 +122,10 @@ export type ParsedContent =
|
|||||||
| SystemContent
|
| SystemContent
|
||||||
| UnknownContent
|
| UnknownContent
|
||||||
|
|
||||||
/**
|
|
||||||
* `local_type` 可能是打包 u64:低 32 位为类型枚举,高 32 位为标志/子类型。
|
|
||||||
*/
|
|
||||||
export function normalizeLocalType(messageType: number | string): number {
|
|
||||||
try {
|
|
||||||
const value =
|
|
||||||
typeof messageType === 'number'
|
|
||||||
? BigInt(Math.trunc(messageType))
|
|
||||||
: BigInt(String(messageType).trim())
|
|
||||||
return Number(value & 0xffffffffn)
|
|
||||||
} catch {
|
|
||||||
const fallback = Number(messageType)
|
|
||||||
return Number.isFinite(fallback) ? Math.trunc(fallback) : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
||||||
const localType = normalizeLocalType(messageType)
|
// Voice rows may keep their binary payload outside msgContent, so an empty
|
||||||
// content string is still a valid voice message.
|
// content string is still a valid voice message.
|
||||||
if (localType === 34) {
|
if (messageType === 34) {
|
||||||
const duration = parseVoiceDurationSeconds(content)
|
const duration = parseVoiceDurationSeconds(content)
|
||||||
return duration === undefined ? { type: 'voice' } : { type: 'voice', duration }
|
return duration === undefined ? { type: 'voice' } : { type: 'voice', duration }
|
||||||
}
|
}
|
||||||
@@ -192,19 +135,11 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
|
|
||||||
const normalized = content.trim()
|
const normalized = content.trim()
|
||||||
|
|
||||||
switch (localType) {
|
switch (messageType) {
|
||||||
case 1:
|
case 1:
|
||||||
return { type: 'text', content: normalized }
|
return { type: 'text', content: normalized }
|
||||||
case 2:
|
|
||||||
return parseLegacyType2(normalized)
|
|
||||||
case 3:
|
case 3:
|
||||||
return parseImageMessage(normalized)
|
return parseImageMessage(normalized)
|
||||||
case 8:
|
|
||||||
return parseLegacyType8(normalized)
|
|
||||||
case 17:
|
|
||||||
return parseForwardBundle(normalized)
|
|
||||||
case 37:
|
|
||||||
return parseFriendVerifyMessage(normalized)
|
|
||||||
case 42:
|
case 42:
|
||||||
return parseCardMessage(normalized)
|
return parseCardMessage(normalized)
|
||||||
case 43:
|
case 43:
|
||||||
@@ -221,7 +156,7 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
case 10002:
|
case 10002:
|
||||||
return parseSystemMessage(normalized)
|
return parseSystemMessage(normalized)
|
||||||
default:
|
default:
|
||||||
return { type: 'unknown', raw: normalized, messageType: localType }
|
return { type: 'unknown', raw: normalized, messageType }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,30 +480,6 @@ function parseLocationMessage(content: string): ParsedContent {
|
|||||||
function parseShareMessage(content: string): ParsedContent {
|
function parseShareMessage(content: string): ParsedContent {
|
||||||
const appMsgType = extractAppMsgType(content)
|
const appMsgType = extractAppMsgType(content)
|
||||||
const isFileMessage = appMsgType === '6' || appMsgType === '74'
|
const isFileMessage = appMsgType === '6' || appMsgType === '74'
|
||||||
if (/<patMsg\b/i.test(content) || /sysmsg[^>]+type=["']pat["']/i.test(content)) {
|
|
||||||
return parsePatMessage(content)
|
|
||||||
}
|
|
||||||
if (/<findernamecard\b/i.test(content)) {
|
|
||||||
return parseFinderNameCard(content)
|
|
||||||
}
|
|
||||||
if (/<productitem\b/i.test(content) && !extractXmlValue(content, 'title')) {
|
|
||||||
return parseProductItem(content)
|
|
||||||
}
|
|
||||||
if (looksLikeMusicShare(content)) {
|
|
||||||
return parseMusicShareCard(content)
|
|
||||||
}
|
|
||||||
if (looksLikeSubscribeCard(content)) {
|
|
||||||
return parseSubscribeCard(content)
|
|
||||||
}
|
|
||||||
if (looksLikeKefuCard(content)) {
|
|
||||||
return parseKefuCard(content)
|
|
||||||
}
|
|
||||||
if (looksLikeStreamVideo(content)) {
|
|
||||||
return parseStreamVideoCard(content)
|
|
||||||
}
|
|
||||||
if (looksLikeGiftCard(content)) {
|
|
||||||
return parseGiftCard(content)
|
|
||||||
}
|
|
||||||
if (appMsgType === '19') {
|
if (appMsgType === '19') {
|
||||||
return parseForwardBundle(content)
|
return parseForwardBundle(content)
|
||||||
}
|
}
|
||||||
@@ -611,25 +522,12 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appMsgType === '2001' || /mmpayhb|receivehongbao|wxpay:\/\/c2cbizmessagehandler\/hongbao/i.test(content)) {
|
if (appMsgType === '2001') {
|
||||||
const pay = parseWcpayInfo(content)
|
|
||||||
const url = decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
|
||||||
return {
|
return {
|
||||||
type: 'redPacket',
|
type: 'redPacket',
|
||||||
title:
|
title: extractXmlValue(content, 'title') || '微信红包',
|
||||||
pay.sendTitle ||
|
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
|
||||||
pay.receiveTitle ||
|
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
||||||
extractXmlValue(content, 'title') ||
|
|
||||||
'微信红包',
|
|
||||||
description:
|
|
||||||
extractXmlValue(content, 'des') ||
|
|
||||||
pay.sceneText ||
|
|
||||||
'恭喜发财,大吉大利',
|
|
||||||
url,
|
|
||||||
pay: {
|
|
||||||
...pay,
|
|
||||||
sendId: pay.sendId || extractSendIdFromUrl(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,12 +547,6 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
const typeVal = extractXmlValue(content, 'type') || ''
|
const typeVal = extractXmlValue(content, 'type') || ''
|
||||||
|
|
||||||
if (!title && !url) {
|
if (!title && !url) {
|
||||||
if (/<productitem\b/i.test(content)) return parseProductItem(content)
|
|
||||||
if (looksLikeMusicShare(content)) return parseMusicShareCard(content)
|
|
||||||
if (looksLikeSubscribeCard(content)) return parseSubscribeCard(content)
|
|
||||||
if (looksLikeKefuCard(content)) return parseKefuCard(content)
|
|
||||||
if (looksLikeStreamVideo(content)) return parseStreamVideoCard(content)
|
|
||||||
if (looksLikeGiftCard(content)) return parseGiftCard(content)
|
|
||||||
return { type: 'unknown', raw: content }
|
return { type: 'unknown', raw: content }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,372 +557,7 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
url,
|
url,
|
||||||
appname,
|
appname,
|
||||||
typeVal,
|
typeVal,
|
||||||
articles: articles.length > 1 ? articles : undefined,
|
articles: articles.length > 1 ? articles : undefined
|
||||||
transfer: typeVal === '2000' ? parseWcpayInfo(content) : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 只读解析 `<wcpayinfo>`(转账 / 红包展示字段;不做支付)。 */
|
|
||||||
function parseWcpayInfo(content: string): TransferPaymentInfo & RedPacketPaymentInfo {
|
|
||||||
const block = /<wcpayinfo>([\s\S]*?)<\/wcpayinfo>/i.exec(content)?.[1] || content
|
|
||||||
const val = (tag: string): string | undefined => {
|
|
||||||
const raw = extractXmlValue(block, tag)
|
|
||||||
return raw ? decodeXmlEntities(raw) || undefined : undefined
|
|
||||||
}
|
|
||||||
const transferStatus = val('transfer_status')
|
|
||||||
const hbStatus = val('hb_status')
|
|
||||||
const receiveStatus = val('receive_status')
|
|
||||||
return {
|
|
||||||
paySubtype: val('paysubtype'),
|
|
||||||
amountText: val('feedesc'),
|
|
||||||
transcationId: val('transcationid'),
|
|
||||||
transferId: val('transferid'),
|
|
||||||
invalidTime: val('invalidtime'),
|
|
||||||
beginTransferTime: val('begintransfertime'),
|
|
||||||
effectiveDate: val('effectivedate'),
|
|
||||||
payMemo: val('pay_memo') || undefined,
|
|
||||||
receiverUsername: val('receiver_username'),
|
|
||||||
payerUsername: val('payer_username'),
|
|
||||||
transferStatus,
|
|
||||||
transferStatusText: describeTransferStatus(transferStatus),
|
|
||||||
transId: val('trans_id'),
|
|
||||||
feeType: val('fee_type'),
|
|
||||||
transferAttach: val('transfer_attach'),
|
|
||||||
refundBankType: val('refund_bank_type'),
|
|
||||||
templateId: val('templateid'),
|
|
||||||
receiveTitle: val('receivertitle'),
|
|
||||||
sendTitle: val('sendertitle'),
|
|
||||||
sceneText: val('scenetext'),
|
|
||||||
senderDes: val('senderdes'),
|
|
||||||
receiverDes: val('receiverdes'),
|
|
||||||
iconUrl: decodeXmlUrl(val('iconurl') || '') || undefined,
|
|
||||||
nativeUrl: val('nativeurl'),
|
|
||||||
hbType: val('hb_type'),
|
|
||||||
hbStatus,
|
|
||||||
receiveStatus,
|
|
||||||
redPacketStatusText: describeRedPacketStatus(hbStatus, receiveStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSendIdFromUrl(url?: string): string | undefined {
|
|
||||||
if (!url) return undefined
|
|
||||||
try {
|
|
||||||
return new URL(url).searchParams.get('sendid') || undefined
|
|
||||||
} catch {
|
|
||||||
return /sendid=(\d+)/i.exec(url)?.[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 拍一拍(`HandlePatMsg` / `<patMsg>`)→ 系统文案。 */
|
|
||||||
function parsePatMessage(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const template =
|
|
||||||
extractXmlValue(decoded, 'template') ||
|
|
||||||
extractXmlValue(decoded, 'pattemplate') ||
|
|
||||||
extractXmlValue(decoded, 'plain') ||
|
|
||||||
''
|
|
||||||
const from = extractXmlValue(decoded, 'fromusername') || extractXmlValue(decoded, 'fromuser')
|
|
||||||
const to = extractXmlValue(decoded, 'tousername') || extractXmlValue(decoded, 'touser')
|
|
||||||
const pat = extractXmlValue(decoded, 'pat') || extractXmlValue(decoded, 'patted')
|
|
||||||
const text =
|
|
||||||
normalizeSystemText(
|
|
||||||
template.replace(/\$from\$/g, from || '').replace(/\$to\$/g, to || '').replace(/\$pat\$/g, pat || '')
|
|
||||||
) || normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '拍了拍'
|
|
||||||
return { type: 'system', content: text, raw: content }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 视频号名片(`<findernamecard>`)→ 只读 share。 */
|
|
||||||
function parseFinderNameCard(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'finderUsername')
|
|
||||||
const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'finderNickname')
|
|
||||||
const title = nickname || username || extractXmlValue(decoded, 'title') || '视频号名片'
|
|
||||||
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'appPageUrl') || '')
|
|
||||||
return {
|
|
||||||
type: 'share',
|
|
||||||
title,
|
|
||||||
des: username && nickname ? username : undefined,
|
|
||||||
url: url || '',
|
|
||||||
appname: '视频号',
|
|
||||||
typeVal: extractAppMsgType(content) || 'findernamecard'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 商品卡(`<productitem>`)→ 只读 share。 */
|
|
||||||
function parseProductItem(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const title =
|
|
||||||
extractXmlValue(decoded, 'productName') ||
|
|
||||||
extractXmlValue(decoded, 'product_name') ||
|
|
||||||
extractXmlValue(decoded, 'title') ||
|
|
||||||
'商品'
|
|
||||||
const des =
|
|
||||||
extractXmlValue(decoded, 'productDesc') ||
|
|
||||||
extractXmlValue(decoded, 'sellingPrice') ||
|
|
||||||
extractXmlValue(decoded, 'referdes')
|
|
||||||
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'productUrl') || '')
|
|
||||||
return {
|
|
||||||
type: 'share',
|
|
||||||
title,
|
|
||||||
des: des || undefined,
|
|
||||||
url: url || '',
|
|
||||||
appname: extractXmlValue(decoded, 'sellername') || '商品',
|
|
||||||
typeVal: extractAppMsgType(content) || 'productitem'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 好友验证(local_type=37)→ 系统文案,避免 unknown 黑块。 */
|
|
||||||
function parseFriendVerifyMessage(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const text = extractSysmsgTemplateText(decoded)
|
|
||||||
if (text) return { type: 'system', content: text, raw: content }
|
|
||||||
const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'NickName')
|
|
||||||
const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'UserName')
|
|
||||||
const contentText =
|
|
||||||
extractXmlValue(decoded, 'content') ||
|
|
||||||
extractXmlValue(decoded, 'bighead') ||
|
|
||||||
extractXmlValue(decoded, 'source')
|
|
||||||
const label = normalizeSystemText(
|
|
||||||
[nickname || username ? `${nickname || username}` : '', contentText || '好友验证消息']
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')
|
|
||||||
)
|
|
||||||
return { type: 'system', content: label || '好友验证消息', raw: content }
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeMusicShare(content: string): boolean {
|
|
||||||
return /songalbumurl|songlyric|musicShareItem|tingListenItem|ListenItem|music_share_item|musicurl|<songlyri|<songalbu/i.test(
|
|
||||||
content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeSubscribeCard(content: string): boolean {
|
|
||||||
return /subscribeMessage|subscribe_msg|SubscribeMsg|updatablemsg|wadynamicpageinf|OnSubscriptionCustom/i.test(
|
|
||||||
content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeKefuCard(content: string): boolean {
|
|
||||||
return /opencustomerservicemsg|wa_app_kefu_message|kefumenu|kf_order|kf_user_|ChatKfTemplate|AppReaderTemplate|template_header/i.test(
|
|
||||||
content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 流视频 / 长视频卡(`streamvideotitle` / `finderMegaVideo`)。 */
|
|
||||||
function looksLikeStreamVideo(content: string): boolean {
|
|
||||||
return /streamvideotitle|streamvideoword|streamvideoweburl|streamvideothumb|finderMegaVideo|finderLiveInvite/i.test(
|
|
||||||
content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 礼物 / 礼品卡(`csgift` / `giftcarditem`)。
|
|
||||||
* 只读展示;**不**调用 `acceptgiftcard` / `preacceptgiftcard` / `getcardgiftinfo`。
|
|
||||||
*/
|
|
||||||
function looksLikeGiftCard(content: string): boolean {
|
|
||||||
return /<csgift\b|<ecsgift\b|<giftcarditem\b|<giftcard\b|giftcarditem|acceptgiftcard/i.test(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 流视频卡 → 只读 share。 */
|
|
||||||
function parseStreamVideoCard(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const title =
|
|
||||||
extractXmlValue(decoded, 'streamvideotitle') ||
|
|
||||||
extractXmlValue(decoded, 'title') ||
|
|
||||||
extractXmlValue(decoded, 'sourcetitle') ||
|
|
||||||
'视频'
|
|
||||||
const des =
|
|
||||||
extractXmlValue(decoded, 'streamvideoword') ||
|
|
||||||
extractXmlValue(decoded, 'des') ||
|
|
||||||
extractXmlValue(decoded, 'contentdescshowtext') ||
|
|
||||||
undefined
|
|
||||||
const url =
|
|
||||||
decodeXmlUrl(
|
|
||||||
extractXmlValue(decoded, 'streamvideoweburl') ||
|
|
||||||
extractXmlValue(decoded, 'url') ||
|
|
||||||
extractXmlValue(decoded, 'weburl')
|
|
||||||
) || ''
|
|
||||||
return {
|
|
||||||
type: 'share',
|
|
||||||
title,
|
|
||||||
des,
|
|
||||||
url,
|
|
||||||
appname: /finderMegaVideo|finderLiveInvite/i.test(content) ? '视频号' : '视频',
|
|
||||||
typeVal: extractAppMsgType(content) || 'streamvideo'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 礼物 / 礼品卡 → 只读 share(无 title 时 system 文案)。 */
|
|
||||||
function parseGiftCard(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const title =
|
|
||||||
extractXmlValue(decoded, 'title') ||
|
|
||||||
extractXmlValue(decoded, 'gifttitle') ||
|
|
||||||
extractXmlValue(decoded, 'cardtitle') ||
|
|
||||||
extractXmlValue(decoded, 'brandname')
|
|
||||||
const des =
|
|
||||||
extractXmlValue(decoded, 'des') ||
|
|
||||||
extractXmlValue(decoded, 'giftwording') ||
|
|
||||||
extractXmlValue(decoded, 'cardwording') ||
|
|
||||||
extractXmlValue(decoded, 'description')
|
|
||||||
const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'cardurl') || '')
|
|
||||||
if (!title && !des) {
|
|
||||||
return {
|
|
||||||
type: 'system',
|
|
||||||
content: normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '礼物卡',
|
|
||||||
raw: content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'share',
|
|
||||||
title: title || '礼物卡',
|
|
||||||
des: des || undefined,
|
|
||||||
url: url || '',
|
|
||||||
appname: extractXmlValue(decoded, 'brandname') || '礼物',
|
|
||||||
typeVal: extractAppMsgType(content) || 'giftcard'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* local_type=2:真机 histogram 未采到;按内容形态尽量落到 system/text,
|
|
||||||
* 否则保留 unknown(见 wechat-message-type-coverage.md)。
|
|
||||||
*/
|
|
||||||
function parseLegacyType2(content: string): ParsedContent {
|
|
||||||
if (/<sysmsg\b|<patMsg\b|revoke/i.test(content)) {
|
|
||||||
return parseSystemMessage(content)
|
|
||||||
}
|
|
||||||
if (!/<[a-zA-Z!]/.test(content)) {
|
|
||||||
return { type: 'text', content }
|
|
||||||
}
|
|
||||||
return parseSystemMessage(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* local_type=8:社区多见于 GIF/大表情变体;无真机样本时按 sticker → image → system
|
|
||||||
* 逐级尝试,避免直接黑块。
|
|
||||||
*/
|
|
||||||
function parseLegacyType8(content: string): ParsedContent {
|
|
||||||
const sticker = parseStickerMessage(content)
|
|
||||||
if (sticker.type === 'sticker' && sticker.md5) return sticker
|
|
||||||
const image = parseImageMessage(content)
|
|
||||||
if (image.type === 'image' && (image.md5 || image.datName)) return image
|
|
||||||
if (/<sysmsg\b|<emoji\b|<msg\b/i.test(content)) {
|
|
||||||
const system = parseSystemMessage(content)
|
|
||||||
if (system.type === 'system' && system.content && system.content !== content) return system
|
|
||||||
}
|
|
||||||
return { type: 'unknown', raw: content, messageType: 8 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 音乐 / 听歌分享(`musicShareItem` / `ListenItem` / `songalbumurl`)→ 只读 share。 */
|
|
||||||
function parseMusicShareCard(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const title =
|
|
||||||
extractXmlValue(decoded, 'title') ||
|
|
||||||
extractXmlValue(decoded, 'songname') ||
|
|
||||||
extractXmlValue(decoded, 'musicTitle') ||
|
|
||||||
'音乐分享'
|
|
||||||
const des =
|
|
||||||
extractXmlValue(decoded, 'des') ||
|
|
||||||
extractXmlValue(decoded, 'singername') ||
|
|
||||||
extractXmlValue(decoded, 'albumname') ||
|
|
||||||
undefined
|
|
||||||
const url =
|
|
||||||
decodeXmlUrl(
|
|
||||||
extractXmlValue(decoded, 'url') ||
|
|
||||||
extractXmlValue(decoded, 'musicurl') ||
|
|
||||||
extractXmlValue(decoded, 'streamweburl') ||
|
|
||||||
extractXmlValue(decoded, 'weburl')
|
|
||||||
) || ''
|
|
||||||
const appname =
|
|
||||||
extractXmlValue(decoded, 'appname') ||
|
|
||||||
extractXmlValue(decoded, 'publisher') ||
|
|
||||||
(looksLikeListenItem(content) ? '听一听' : '音乐')
|
|
||||||
return {
|
|
||||||
type: 'share',
|
|
||||||
title,
|
|
||||||
des,
|
|
||||||
url,
|
|
||||||
appname,
|
|
||||||
typeVal: extractAppMsgType(content) || 'music'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeListenItem(content: string): boolean {
|
|
||||||
return /tingListenItem|ListenItem|MMLISTEN_ITEM_TYPE/i.test(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅号 / 可更新消息模板(`subscribeMessage` / `updatablemsg`)→ 只读 share。
|
|
||||||
* 模板头 `template_header` / `template_detail` 作标题与摘要。
|
|
||||||
*/
|
|
||||||
function parseSubscribeCard(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const title =
|
|
||||||
extractXmlValue(decoded, 'template_header') ||
|
|
||||||
extractXmlValue(decoded, 'title') ||
|
|
||||||
extractXmlValue(decoded, 'templatetitle') ||
|
|
||||||
'订阅消息'
|
|
||||||
const des =
|
|
||||||
extractXmlValue(decoded, 'template_detail') ||
|
|
||||||
extractXmlValue(decoded, 'des') ||
|
|
||||||
extractXmlValue(decoded, 'digest') ||
|
|
||||||
undefined
|
|
||||||
const url =
|
|
||||||
decodeXmlUrl(
|
|
||||||
extractXmlValue(decoded, 'url') ||
|
|
||||||
extractXmlValue(decoded, 'jumpUrl') ||
|
|
||||||
extractXmlValue(decoded, 'weburl')
|
|
||||||
) || ''
|
|
||||||
return {
|
|
||||||
type: 'share',
|
|
||||||
title,
|
|
||||||
des,
|
|
||||||
url,
|
|
||||||
appname: extractXmlValue(decoded, 'appname') || '订阅消息',
|
|
||||||
typeVal: extractAppMsgType(content) || 'subscribe'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 客服 / 门店模板卡(`opencustomerservicemsg` / `wa_app_kefu_message` / `kefumenu`)
|
|
||||||
* → 只读 share;无 title 时退化为 system 文案,避免 unknown 黑块。
|
|
||||||
*/
|
|
||||||
function parseKefuCard(content: string): ParsedContent {
|
|
||||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
|
||||||
const title =
|
|
||||||
extractXmlValue(decoded, 'template_header') ||
|
|
||||||
extractXmlValue(decoded, 'title') ||
|
|
||||||
extractXmlValue(decoded, 'kf_title') ||
|
|
||||||
extractXmlValue(decoded, 'templatetitle')
|
|
||||||
const des =
|
|
||||||
extractXmlValue(decoded, 'template_detail') ||
|
|
||||||
extractXmlValue(decoded, 'kf_order_text') ||
|
|
||||||
extractXmlValue(decoded, 'des') ||
|
|
||||||
extractXmlValue(decoded, 'content')
|
|
||||||
const url =
|
|
||||||
decodeXmlUrl(
|
|
||||||
extractXmlValue(decoded, 'url') ||
|
|
||||||
extractXmlValue(decoded, 'jumpUrl') ||
|
|
||||||
extractXmlValue(decoded, 'weburl')
|
|
||||||
) || ''
|
|
||||||
const appname =
|
|
||||||
extractXmlValue(decoded, 'kf_user_name') ||
|
|
||||||
extractXmlValue(decoded, 'appname') ||
|
|
||||||
'客服消息'
|
|
||||||
if (!title && !des) {
|
|
||||||
return {
|
|
||||||
type: 'system',
|
|
||||||
content: normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '客服消息',
|
|
||||||
raw: content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'share',
|
|
||||||
title: title || '客服消息',
|
|
||||||
des: des || undefined,
|
|
||||||
url: url || '',
|
|
||||||
appname,
|
|
||||||
typeVal: extractAppMsgType(content) || 'kefu'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,652 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
if (this.runScheduledReportMigration(this.state)) this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
|
||||||
|
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.state.rules = [...this.state.rules, rule]
|
||||||
|
this.persist()
|
||||||
|
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.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
|
||||||
|
this.persist()
|
||||||
|
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.state.rules = [...this.state.rules, rule]
|
||||||
|
this.persist()
|
||||||
|
return structuredClone(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRule(id: string): boolean {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const key = String(id || '').trim()
|
||||||
|
const before = this.state.rules.length
|
||||||
|
this.state.rules = this.state.rules.filter((rule) => rule.id !== key)
|
||||||
|
if (this.state.rules.length === before) return false
|
||||||
|
this.persist()
|
||||||
|
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.state.rules = this.state.rules.map((item, at) => (at === index ? next : item))
|
||||||
|
this.persist()
|
||||||
|
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
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
|
||||||
|
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)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
@@ -180,8 +180,6 @@ export interface GroupSnapshot {
|
|||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
groupName?: string
|
groupName?: string
|
||||||
/** 只读 roominfo(contact.db / chatroom);字段可能随版本缺失。 */
|
|
||||||
roomInfo?: GroupRoomInfo
|
|
||||||
members: {
|
members: {
|
||||||
wxid: string
|
wxid: string
|
||||||
nickname: string
|
nickname: string
|
||||||
@@ -192,18 +190,6 @@ export interface GroupSnapshot {
|
|||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 群 roominfo 只读字段(列名兼容多版本;缺失则为 undefined)。 */
|
|
||||||
export interface GroupRoomInfo {
|
|
||||||
roomId: string
|
|
||||||
owner?: string
|
|
||||||
announcement?: string
|
|
||||||
announcementEditor?: string
|
|
||||||
maxMemberCount?: number
|
|
||||||
chatName?: string
|
|
||||||
openImAccountType?: string
|
|
||||||
isOpenIm?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupMembershipSnapshot {
|
export interface GroupMembershipSnapshot {
|
||||||
roomId: string
|
roomId: string
|
||||||
memberIds: string[]
|
memberIds: string[]
|
||||||
@@ -1021,7 +1007,7 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
|||||||
avatar: member.m_nsHeadImgUrl || ''
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return { roomId, memberCount: members.length, members, roomInfo: wcdb4Client.getRoomInfo(roomId) || undefined }
|
return { roomId, memberCount: members.length, members }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
||||||
@@ -1046,11 +1032,24 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
|
|||||||
roomId,
|
roomId,
|
||||||
groupName: session?.nickname || undefined,
|
groupName: session?.nickname || undefined,
|
||||||
memberCount: members.length,
|
memberCount: members.length,
|
||||||
members,
|
members
|
||||||
roomInfo: (await wcdb4Client.getRoomInfoAsync(roomId)) || undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把群会话 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。 */
|
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
|
||||||
export async function getGroupMemberIdsAsync(
|
export async function getGroupMemberIdsAsync(
|
||||||
roomId: string
|
roomId: string
|
||||||
|
|||||||
@@ -2,19 +2,17 @@ import { app, BrowserWindow } from 'electron'
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import * as chat from './chat-service'
|
import * as chat from './chat-service'
|
||||||
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
|
|
||||||
import {
|
import {
|
||||||
buildMemberLeftNotification,
|
|
||||||
findRemovedGroupMembers,
|
findRemovedGroupMembers,
|
||||||
groupExitMemberName,
|
groupExitMemberName,
|
||||||
normalizeGroupExitNotificationTemplate,
|
|
||||||
validateGroupExitNotificationTemplate,
|
|
||||||
type GroupExitMonitorEvent,
|
type GroupExitMonitorEvent,
|
||||||
type GroupExitMonitorMember,
|
type GroupExitMonitorMember,
|
||||||
type GroupExitNotificationStatus,
|
|
||||||
type GroupExitNotificationState,
|
|
||||||
type GroupExitMonitorState
|
type GroupExitMonitorState
|
||||||
} from '../../shared/group-exit-monitor'
|
} from '../../shared/group-exit-monitor'
|
||||||
|
import {
|
||||||
|
toGroupMemberExitedEvent,
|
||||||
|
type GroupMemberExitedEvent
|
||||||
|
} from '../../shared/group-exit-event'
|
||||||
|
|
||||||
type StoredState = {
|
type StoredState = {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
@@ -23,9 +21,14 @@ type StoredState = {
|
|||||||
lastReadAt?: number
|
lastReadAt?: number
|
||||||
monitorSelectionConfigured?: boolean
|
monitorSelectionConfigured?: boolean
|
||||||
monitoredRoomIds?: string[]
|
monitoredRoomIds?: string[]
|
||||||
notificationRoomIds?: string[]
|
|
||||||
notificationTemplate?: unknown
|
|
||||||
snapshots?: Partial<StoredGroupSnapshot>[]
|
snapshots?: Partial<StoredGroupSnapshot>[]
|
||||||
|
/*
|
||||||
|
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
|
||||||
|
*
|
||||||
|
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
|
||||||
|
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
|
||||||
|
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupSnapshotRecord = {
|
type GroupSnapshotRecord = {
|
||||||
@@ -57,19 +60,30 @@ type StoredGroupSnapshot = Pick<
|
|||||||
|
|
||||||
const DB_CHANGE_DEBOUNCE_MS = 350
|
const DB_CHANGE_DEBOUNCE_MS = 350
|
||||||
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
||||||
|
/**
|
||||||
|
* **只约束「一次状态回传带多少条」**,不是存储上限。
|
||||||
|
*
|
||||||
|
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
|
||||||
|
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
|
||||||
|
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
|
||||||
|
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
|
||||||
|
*/
|
||||||
const MAX_EVENTS = 500
|
const MAX_EVENTS = 500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群事件处理器。
|
||||||
|
*
|
||||||
|
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
|
||||||
|
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
|
||||||
|
*/
|
||||||
|
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
|
||||||
|
|
||||||
export interface GroupExitMonitorServiceDependencies {
|
export interface GroupExitMonitorServiceDependencies {
|
||||||
actionGateway?: GroupExitActionGateway
|
onGroupExit?: GroupExitEventHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
|
|
||||||
Partial<
|
|
||||||
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
|
|
||||||
>
|
|
||||||
|
|
||||||
class GroupExitMonitorService {
|
class GroupExitMonitorService {
|
||||||
private readonly actionGateway: GroupExitActionGateway
|
private onGroupExit: GroupExitEventHandler | undefined
|
||||||
private active = false
|
private active = false
|
||||||
private enabled = true
|
private enabled = true
|
||||||
private nativeMonitorActive = false
|
private nativeMonitorActive = false
|
||||||
@@ -87,7 +101,6 @@ class GroupExitMonitorService {
|
|||||||
private events: GroupExitMonitorEvent[] = []
|
private events: GroupExitMonitorEvent[] = []
|
||||||
private monitorSelectionConfigured = true
|
private monitorSelectionConfigured = true
|
||||||
private monitoredRoomIds = new Set<string>()
|
private monitoredRoomIds = new Set<string>()
|
||||||
private notificationRoomIds = new Set<string>()
|
|
||||||
private loaded = false
|
private loaded = false
|
||||||
private accountRoot = ''
|
private accountRoot = ''
|
||||||
private groupNamesByRoomId = new Map<string, string>()
|
private groupNamesByRoomId = new Map<string, string>()
|
||||||
@@ -95,24 +108,34 @@ class GroupExitMonitorService {
|
|||||||
private eventSequence = 0
|
private eventSequence = 0
|
||||||
private scopeGeneration = 0
|
private scopeGeneration = 0
|
||||||
private legacyFallbackLogged = false
|
private legacyFallbackLogged = false
|
||||||
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
|
||||||
|
|
||||||
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
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 {
|
getState(): GroupExitMonitorState {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
return {
|
return {
|
||||||
events: [...this.events],
|
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
|
||||||
|
events: this.events.slice(0, MAX_EVENTS),
|
||||||
|
/** 永久保留的事件总数(`events` 只是最新的一批)。 */
|
||||||
|
totalEventCount: this.events.length,
|
||||||
enabled: this.enabled,
|
enabled: this.enabled,
|
||||||
running: this.enabled && this.active && chat.isReady(),
|
running: this.enabled && this.active && chat.isReady(),
|
||||||
nativeMonitorActive: this.nativeMonitorActive,
|
nativeMonitorActive: this.nativeMonitorActive,
|
||||||
monitoredGroupCount: this.snapshots.size,
|
monitoredGroupCount: this.snapshots.size,
|
||||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
|
||||||
notificationTemplate: this.notificationTemplate,
|
|
||||||
lastCheckedAt: this.lastCheckedAt,
|
lastCheckedAt: this.lastCheckedAt,
|
||||||
lastReadAt: this.lastReadAt,
|
lastReadAt: this.lastReadAt,
|
||||||
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
|
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
|
||||||
@@ -132,12 +155,11 @@ class GroupExitMonitorService {
|
|||||||
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
|
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
|
||||||
(!this.accountRoot && this.snapshots.size > 0)
|
(!this.accountRoot && this.snapshots.size > 0)
|
||||||
) {
|
) {
|
||||||
this.actionGateway.clearMemberEvents?.()
|
|
||||||
this.events = []
|
this.events = []
|
||||||
|
this.rewriteEventsToDisk([])
|
||||||
this.lastReadAt = 0
|
this.lastReadAt = 0
|
||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
this.monitoredRoomIds.clear()
|
this.monitoredRoomIds.clear()
|
||||||
this.notificationRoomIds.clear()
|
|
||||||
this.snapshots.clear()
|
this.snapshots.clear()
|
||||||
this.groupNamesByRoomId.clear()
|
this.groupNamesByRoomId.clear()
|
||||||
}
|
}
|
||||||
@@ -208,10 +230,13 @@ class GroupExitMonitorService {
|
|||||||
return this.getState()
|
return this.getState()
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMonitoredRoomIds(
|
/**
|
||||||
roomIds: string[],
|
* 保存**监控范围**。
|
||||||
notificationRoomIds: string[] = []
|
*
|
||||||
): Promise<GroupExitMonitorState> {
|
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
|
||||||
|
* 本服务不再持有任何通知配置。
|
||||||
|
*/
|
||||||
|
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
this.eventSequence += 1
|
this.eventSequence += 1
|
||||||
this.scopeGeneration += 1
|
this.scopeGeneration += 1
|
||||||
@@ -225,10 +250,6 @@ class GroupExitMonitorService {
|
|||||||
}
|
}
|
||||||
this.monitoredRoomIds = nextMonitoredRoomIds
|
this.monitoredRoomIds = nextMonitoredRoomIds
|
||||||
this.groupNamesRefreshPending = true
|
this.groupNamesRefreshPending = true
|
||||||
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
|
|
||||||
this.notificationRoomIds = new Set(
|
|
||||||
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
|
|
||||||
)
|
|
||||||
this.lastCheckedAt = undefined
|
this.lastCheckedAt = undefined
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
@@ -261,22 +282,41 @@ class GroupExitMonitorService {
|
|||||||
return this.getState()
|
return this.getState()
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotificationTemplate(value: unknown): GroupExitMonitorState {
|
/**
|
||||||
|
* 按群 / 时间范围查退群事件(档案合并展示用)。
|
||||||
|
*
|
||||||
|
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
|
||||||
|
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
|
||||||
|
* 直接打在内存里的完整历史上(事件是永久保留的)。
|
||||||
|
*
|
||||||
|
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
|
||||||
|
*/
|
||||||
|
listEvents(
|
||||||
|
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
|
||||||
|
): GroupExitMonitorEvent[] {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
const result = validateGroupExitNotificationTemplate(value)
|
const roomId = String(query.roomId || '').trim()
|
||||||
if (!result.valid || !result.template) {
|
const since = Number(query.sinceMs)
|
||||||
throw new Error(result.error || '退群监测模板无效')
|
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
|
return result
|
||||||
this.save()
|
|
||||||
this.broadcast()
|
|
||||||
return this.getState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearEvents(): GroupExitMonitorState {
|
clearEvents(): GroupExitMonitorState {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
this.events = []
|
this.events = []
|
||||||
this.actionGateway.clearMemberEvents?.()
|
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
|
||||||
|
this.rewriteEventsToDisk([])
|
||||||
this.lastReadAt = Date.now()
|
this.lastReadAt = Date.now()
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
@@ -399,7 +439,7 @@ class GroupExitMonitorService {
|
|||||||
groups: GroupMembershipRecord[],
|
groups: GroupMembershipRecord[],
|
||||||
scopeGeneration: number
|
scopeGeneration: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
|
const exits: GroupExitMonitorEvent[] = []
|
||||||
let changedGroups = 0
|
let changedGroups = 0
|
||||||
for (const membership of groups) {
|
for (const membership of groups) {
|
||||||
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
|
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
|
||||||
@@ -430,10 +470,9 @@ class GroupExitMonitorService {
|
|||||||
if (previous && next.members.length < previous.members.length) {
|
if (previous && next.members.length < previous.members.length) {
|
||||||
const removed = findRemovedGroupMembers(previous.members, next.members)
|
const removed = findRemovedGroupMembers(previous.members, next.members)
|
||||||
for (const member of removed) {
|
for (const member of removed) {
|
||||||
|
// 一人一条事件、一条通知 —— 保持既有产品语义,不聚合。
|
||||||
const event = this.recordExit(next, member, previous.members.length, next.members.length)
|
const event = this.recordExit(next, member, previous.members.length, next.members.length)
|
||||||
if (event && this.notificationRoomIds.has(next.roomId)) {
|
if (event) exits.push(event)
|
||||||
notifications.push({ group: next, event })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,12 +487,11 @@ class GroupExitMonitorService {
|
|||||||
return changedGroups
|
return changedGroups
|
||||||
}
|
}
|
||||||
this.lastCheckedAt = Date.now()
|
this.lastCheckedAt = Date.now()
|
||||||
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
|
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
|
||||||
|
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
if (notifications.length) {
|
for (const event of exits) this.emitGroupExit(event)
|
||||||
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
|
|
||||||
}
|
|
||||||
return changedGroups
|
return changedGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,129 +627,145 @@ class GroupExitMonitorService {
|
|||||||
currentCount,
|
currentCount,
|
||||||
delta: currentCount - previousCount,
|
delta: currentCount - previousCount,
|
||||||
message,
|
message,
|
||||||
detectedAt,
|
detectedAt
|
||||||
notificationStatus: 'not_requested'
|
|
||||||
}
|
}
|
||||||
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(
|
console.log(
|
||||||
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
||||||
)
|
)
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyGroup(
|
/**
|
||||||
group: GroupSnapshotRecord,
|
* 把退群事件交给自动化 —— **不等待**。
|
||||||
event: GroupExitMonitorEvent,
|
*
|
||||||
idempotencyKey = `member_left_notification:${event.id}`
|
* 三条约束:
|
||||||
): Promise<void> {
|
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
|
||||||
event.notificationStatus = 'pending'
|
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
|
||||||
event.notification = { status: 'pending' }
|
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
|
||||||
this.save()
|
*/
|
||||||
this.broadcast()
|
private emitGroupExit(event: GroupExitMonitorEvent): void {
|
||||||
|
const handler = this.onGroupExit
|
||||||
|
if (!handler) return
|
||||||
try {
|
try {
|
||||||
const result = await this.actionGateway.execute({
|
void Promise.resolve(
|
||||||
idempotencyKey,
|
handler(toGroupMemberExitedEvent(event))
|
||||||
origin: 'member_monitor',
|
).catch((error) => {
|
||||||
purpose: 'member_left_notification',
|
|
||||||
triggerType: 'automation',
|
|
||||||
sourceId: event.id,
|
|
||||||
recipient: {
|
|
||||||
type: 'group',
|
|
||||||
id: group.roomId,
|
|
||||||
name: group.groupName
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: 'text',
|
|
||||||
text: buildMemberLeftNotification(event, this.notificationTemplate)
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
memberId: event.memberWxid,
|
|
||||||
memberName: event.memberName,
|
|
||||||
detectedAt: event.detectedAt,
|
|
||||||
eventType: 'member_left',
|
|
||||||
eventRoomId: event.roomId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const notification: GroupExitNotificationState = {
|
|
||||||
status: result.status,
|
|
||||||
actionId: result.actionId,
|
|
||||||
decision: result.decision,
|
|
||||||
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
|
||||||
...(result.reason ? { reason: result.reason } : {}),
|
|
||||||
startedAt: result.startedAt,
|
|
||||||
finishedAt: result.finishedAt
|
|
||||||
}
|
|
||||||
event.notificationStatus = result.status
|
|
||||||
event.notification = notification
|
|
||||||
if (result.status !== 'sent') {
|
|
||||||
console.warn(
|
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) {
|
} catch (error) {
|
||||||
event.notificationStatus = 'failed'
|
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
|
||||||
event.notification = {
|
|
||||||
status: 'failed',
|
|
||||||
errorCode: 'UNKNOWN',
|
|
||||||
reason: error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
|
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
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 {
|
private filePath(): string {
|
||||||
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
|
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 {
|
private ensureLoaded(): void {
|
||||||
if (this.loaded) return
|
if (this.loaded) return
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
try {
|
try {
|
||||||
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
||||||
this.enabled = stored.enabled !== false
|
this.enabled = stored.enabled !== false
|
||||||
this.events = normalizeEvents(stored.events)
|
// 事件从 append-only 文件读;状态文件不再承载它们。
|
||||||
this.actionGateway.registerMemberEvents?.(this.events)
|
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.lastReadAt = Number(stored.lastReadAt) || 0
|
||||||
this.accountRoot = String(stored.accountRoot || '')
|
this.accountRoot = String(stored.accountRoot || '')
|
||||||
// 没有显式范围时按空范围处理,保留已有选择。
|
// 没有显式范围时按空范围处理,保留已有选择。
|
||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
|
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)
|
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
|
||||||
} catch {
|
} catch {
|
||||||
// 首次启动或文件损坏时从空记录开始。
|
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
|
||||||
this.events = []
|
// 不该被状态文件的问题连累,仍然读回来。
|
||||||
|
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
|
||||||
this.enabled = true
|
this.enabled = true
|
||||||
this.lastReadAt = 0
|
this.lastReadAt = 0
|
||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
this.monitoredRoomIds.clear()
|
this.monitoredRoomIds.clear()
|
||||||
this.notificationRoomIds.clear()
|
|
||||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
|
||||||
this.snapshots.clear()
|
this.snapshots.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,12 +779,11 @@ class GroupExitMonitorService {
|
|||||||
{
|
{
|
||||||
accountRoot: this.accountRoot,
|
accountRoot: this.accountRoot,
|
||||||
enabled: this.enabled,
|
enabled: this.enabled,
|
||||||
events: this.events,
|
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
|
||||||
|
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
|
||||||
lastReadAt: this.lastReadAt,
|
lastReadAt: this.lastReadAt,
|
||||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
|
||||||
notificationTemplate: this.notificationTemplate,
|
|
||||||
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
|
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
|
||||||
},
|
},
|
||||||
{ spaces: 2 }
|
{ spaces: 2 }
|
||||||
@@ -927,39 +980,28 @@ function normalizeEvents(
|
|||||||
? Number(value.delta)
|
? Number(value.delta)
|
||||||
: currentCount - previousCount,
|
: currentCount - previousCount,
|
||||||
message: String(value.message || `${memberName}退出了${groupName}`),
|
message: String(value.message || `${memberName}退出了${groupName}`),
|
||||||
detectedAt,
|
detectedAt
|
||||||
...(value.notificationStatus
|
|
||||||
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
|
|
||||||
: {}),
|
|
||||||
...(value.notification && typeof value.notification === 'object'
|
|
||||||
? { notification: normalizeNotification(value.notification) }
|
|
||||||
: {})
|
|
||||||
})
|
})
|
||||||
if (normalized.length >= MAX_EVENTS) break
|
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
|
||||||
|
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
|
||||||
|
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
|
/**
|
||||||
const status = String(value || '').trim()
|
* 事件在内存里恒定保持「**新在前**」。
|
||||||
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
|
*
|
||||||
? status
|
* 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、
|
||||||
: 'not_requested'
|
* `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、
|
||||||
}
|
* 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。
|
||||||
|
*
|
||||||
function normalizeNotification(value: object): GroupExitNotificationState {
|
* 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」
|
||||||
const input = value as Partial<GroupExitNotificationState>
|
* 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的
|
||||||
return {
|
* 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。
|
||||||
status: normalizeNotificationStatus(input.status),
|
*/
|
||||||
...(input.actionId ? { actionId: String(input.actionId) } : {}),
|
function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] {
|
||||||
...(input.decision === 'allow' || input.decision === 'block'
|
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
|
||||||
? { decision: input.decision }
|
|
||||||
: {}),
|
|
||||||
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
|
|
||||||
...(input.reason ? { reason: String(input.reason) } : {}),
|
|
||||||
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
|
|
||||||
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isContactEvent(rawPayload: string): boolean {
|
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 }
|
||||||
@@ -320,23 +320,11 @@ export class LocalQueryApiService {
|
|||||||
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
|
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
/** 收藏关键词检索(favorite.db,只读)。 */
|
|
||||||
private favoritesSearch:
|
|
||||||
| ((query: string, limit: number) => Promise<Array<{ text: string; timestamp?: number }>>)
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly knowledge?: KnowledgeSearchService,
|
private readonly knowledge?: KnowledgeSearchService,
|
||||||
private readonly nowProvider: () => Date = () => new Date()
|
private readonly nowProvider: () => Date = () => new Date()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 注入收藏检索(`FavoritesService.searchHits`),供 `search_messages` 合并只读命中。 */
|
|
||||||
setFavoritesSearchProvider(
|
|
||||||
provider: (query: string, limit: number) => Promise<Array<{ text: string; timestamp?: number }>>
|
|
||||||
): void {
|
|
||||||
this.favoritesSearch = provider
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注入图片文字索引覆盖度提供者。
|
* 注入图片文字索引覆盖度提供者。
|
||||||
*
|
*
|
||||||
@@ -519,30 +507,14 @@ export class LocalQueryApiService {
|
|||||||
enrichmentMs,
|
enrichmentMs,
|
||||||
...(knowledgeTiming ? { knowledge: knowledgeTiming } : {})
|
...(knowledgeTiming ? { knowledge: knowledgeTiming } : {})
|
||||||
}
|
}
|
||||||
let favoriteEvidence: QueryEvidenceItem[] = []
|
|
||||||
if (this.favoritesSearch && !targetView) {
|
|
||||||
try {
|
|
||||||
const hits = await this.favoritesSearch(request.query, Math.min(10, limit))
|
|
||||||
favoriteEvidence = hits.map((hit, index) => ({
|
|
||||||
messageRef: `fav-${index}`,
|
|
||||||
conversationName: '收藏',
|
|
||||||
timestamp: hit.timestamp ?? 0,
|
|
||||||
sender: '收藏',
|
|
||||||
sourceKind: 'other' as QueryMessageType,
|
|
||||||
text: hit.text
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
favoriteEvidence = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
status: 'completed' as const,
|
status: 'completed' as const,
|
||||||
...(targetView ? { target: targetView } : {}),
|
...(targetView ? { target: targetView } : {}),
|
||||||
resolvedTimeRange: range,
|
resolvedTimeRange: range,
|
||||||
coverage: { state: this.searchCoverage(found, requestedEnd) },
|
coverage: { state: this.searchCoverage(found, requestedEnd) },
|
||||||
probeCount: probes.length,
|
probeCount: probes.length,
|
||||||
evidenceCount: found.evidence.size + favoriteEvidence.length,
|
evidenceCount: found.evidence.size,
|
||||||
evidence: [...Array.from(found.evidence.values()).slice(0, limit), ...favoriteEvidence],
|
evidence: Array.from(found.evidence.values()).slice(0, limit),
|
||||||
scope: corpus.scope,
|
scope: corpus.scope,
|
||||||
indexLatestAt: found.indexLatestAt,
|
indexLatestAt: found.indexLatestAt,
|
||||||
sourceLatestAt: found.sourceLatestAt,
|
sourceLatestAt: found.sourceLatestAt,
|
||||||
|
|||||||
@@ -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,
|
personalWechatSendService,
|
||||||
type PersonalWechatSendService
|
type PersonalWechatSendService
|
||||||
} from './personal-wechat-send-service'
|
} from './personal-wechat-send-service'
|
||||||
|
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the detailed sender diagnostics into a small contract that other
|
* 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 {
|
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> {
|
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
|
||||||
|
if (this.platform === 'darwin') {
|
||||||
|
return this.fromMacRuntime()
|
||||||
|
}
|
||||||
const senderStatus = await this.sender.getStatus()
|
const senderStatus = await this.sender.getStatus()
|
||||||
return this.fromSenderStatus(senderStatus)
|
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 {
|
private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState {
|
||||||
if (senderStatus.platform === 'win32') {
|
if (senderStatus.platform === 'win32') {
|
||||||
if (senderStatus.canSend) return 'ready'
|
if (senderStatus.canSend) return 'ready'
|
||||||
@@ -60,6 +83,7 @@ export class PersonalWechatCapabilityService {
|
|||||||
return 'unsupported'
|
return 'unsupported'
|
||||||
}
|
}
|
||||||
if (senderStatus.state === 'error') return 'error'
|
if (senderStatus.state === 'error') return 'error'
|
||||||
|
if (senderStatus.state === 'runtime_missing') return 'unconfigured'
|
||||||
const hasCurrentBinding = Boolean(
|
const hasCurrentBinding = Boolean(
|
||||||
senderStatus.endpointReady &&
|
senderStatus.endpointReady &&
|
||||||
senderStatus.attachReady &&
|
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
|
PersonalWechatVoiceRuntimeComponent
|
||||||
} from '../../shared/personal-wechat-voice-runtime'
|
} from '../../shared/personal-wechat-voice-runtime'
|
||||||
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
|
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
|
||||||
import {
|
import { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service'
|
||||||
buildPersonalWechatRuntimeEnvironment,
|
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||||
findPersonalWechatRuntime
|
|
||||||
} from './personal-wechat-send-service'
|
|
||||||
import type { RuntimeLayout } from './personal-wechat-send-service'
|
|
||||||
import { appLogger } from '../app-logger'
|
import { appLogger } from '../app-logger'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
@@ -37,8 +34,8 @@ type CommandRunner = (
|
|||||||
interface PersonalWechatVoiceEnvironmentServiceOptions {
|
interface PersonalWechatVoiceEnvironmentServiceOptions {
|
||||||
platform?: NodeJS.Platform
|
platform?: NodeJS.Platform
|
||||||
architecture?: string
|
architecture?: string
|
||||||
findRuntime?: () => RuntimeLayout | null
|
isRuntimePresent?: () => boolean
|
||||||
buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
buildEnvironment?: () => NodeJS.ProcessEnv
|
||||||
runCommand?: CommandRunner
|
runCommand?: CommandRunner
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
}
|
}
|
||||||
@@ -100,18 +97,26 @@ function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultIsRuntimePresent(): boolean {
|
||||||
|
try {
|
||||||
|
return macWechatRuntimeManager.isRuntimePresent()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class PersonalWechatVoiceEnvironmentService {
|
export class PersonalWechatVoiceEnvironmentService {
|
||||||
private readonly platform: NodeJS.Platform
|
private readonly platform: NodeJS.Platform
|
||||||
private readonly architecture: string
|
private readonly architecture: string
|
||||||
private readonly findRuntime: () => RuntimeLayout | null
|
private readonly isRuntimePresent: () => boolean
|
||||||
private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
private readonly buildEnvironment: () => NodeJS.ProcessEnv
|
||||||
private readonly runCommand: CommandRunner
|
private readonly runCommand: CommandRunner
|
||||||
private readonly now: () => Date
|
private readonly now: () => Date
|
||||||
|
|
||||||
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
|
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
|
||||||
this.platform = options.platform || process.platform
|
this.platform = options.platform || process.platform
|
||||||
this.architecture = options.architecture || process.arch
|
this.architecture = options.architecture || process.arch
|
||||||
this.findRuntime = options.findRuntime || findPersonalWechatRuntime
|
this.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent
|
||||||
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
|
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
|
||||||
this.runCommand = options.runCommand || runCommand
|
this.runCommand = options.runCommand || runCommand
|
||||||
this.now = options.now || (() => new Date())
|
this.now = options.now || (() => new Date())
|
||||||
@@ -123,8 +128,8 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logEnvironmentLine('Checking voice encoding environment')
|
logEnvironmentLine('Checking voice encoding environment')
|
||||||
const runtime = this.findRuntime()
|
const runtimeReady = this.isRuntimePresent()
|
||||||
const environment = this.buildEnvironment(runtime?.root)
|
const environment = this.buildEnvironment()
|
||||||
const python = blankComponent()
|
const python = blankComponent()
|
||||||
const pilk = blankComponent()
|
const pilk = blankComponent()
|
||||||
const ffmpeg = blankComponent()
|
const ffmpeg = blankComponent()
|
||||||
@@ -197,26 +202,25 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
|
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 = {
|
const result: PersonalWechatVoiceEncodingEnvironment = {
|
||||||
state: ready ? 'ready' : 'incomplete',
|
state: ready ? 'ready' : 'incomplete',
|
||||||
ready,
|
ready,
|
||||||
checkedAt: this.now().toISOString(),
|
checkedAt: this.now().toISOString(),
|
||||||
runtimeReady: Boolean(runtime),
|
runtimeReady,
|
||||||
...(runtime ? { runtimeRoot: runtime.root } : {}),
|
|
||||||
python,
|
python,
|
||||||
pilk,
|
pilk,
|
||||||
ffmpeg,
|
ffmpeg,
|
||||||
encoder: pilk.ready
|
encoder: pilk.ready
|
||||||
? 'pilk'
|
? 'pilk'
|
||||||
: runtime && python.ready && ffmpeg.ready
|
: runtimeReady && python.ready && ffmpeg.ready
|
||||||
? 'go-silk'
|
? 'silk'
|
||||||
: 'unavailable',
|
: 'unavailable',
|
||||||
message: ready
|
message: ready
|
||||||
? '语音编码环境正常,可以使用 pilk 编码'
|
? '语音编码环境正常,可以使用 pilk 编码'
|
||||||
: runtime
|
: runtimeReady
|
||||||
? '语音编码环境不完整,OneBot 可能回退到 go-silk'
|
? '语音编码环境不完整,语音将回退到内置 SILK 编码'
|
||||||
: '微信发送组件尚未安装,请先准备 OneBot 运行时'
|
: '当前版本暂未提供微信消息发送功能'
|
||||||
}
|
}
|
||||||
logEnvironmentLine(
|
logEnvironmentLine(
|
||||||
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
|
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
|
||||||
@@ -245,7 +249,7 @@ export class PersonalWechatVoiceEnvironmentService {
|
|||||||
pythonExecutable,
|
pythonExecutable,
|
||||||
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
|
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
|
||||||
{
|
{
|
||||||
env: this.buildEnvironment(before.runtimeRoot || undefined),
|
env: this.buildEnvironment(),
|
||||||
timeout: INSTALL_TIMEOUT_MS
|
timeout: INSTALL_TIMEOUT_MS
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ export interface ScheduledReportApiDependencies {
|
|||||||
| 'deleteTask'
|
| 'deleteTask'
|
||||||
| 'setTaskEnabled'
|
| 'setTaskEnabled'
|
||||||
| 'runScheduledReportNow'
|
| 'runScheduledReportNow'
|
||||||
> &
|
>
|
||||||
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
|
|
||||||
getCapability: () => Promise<PersonalWechatSendCapability>
|
getCapability: () => Promise<PersonalWechatSendCapability>
|
||||||
listContacts: () => FormattedContact[]
|
listContacts: () => FormattedContact[]
|
||||||
isDatabaseReady: () => boolean
|
isDatabaseReady: () => boolean
|
||||||
@@ -250,16 +249,20 @@ export class ScheduledReportApiService {
|
|||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrySend(executionId: string): Promise<ScheduledReportExecution> {
|
/**
|
||||||
const retry = this.deps.service.retryScheduledReportSend
|
* 「重新发送」不再支持。
|
||||||
if (!retry) {
|
*
|
||||||
throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报')
|
* 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG
|
||||||
}
|
* 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发
|
||||||
const result = await retry.call(this.deps.service, executionId)
|
* 语义,不如**明确返回 501**。
|
||||||
if (!result.data) {
|
* 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。
|
||||||
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
|
*/
|
||||||
}
|
async retrySend(_executionId: string): Promise<ScheduledReportExecution> {
|
||||||
return result.data
|
throw new ScheduledReportApiError(
|
||||||
|
501,
|
||||||
|
'not_supported',
|
||||||
|
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
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
|
showStartupProgress: boolean
|
||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: TextToSpeechModel
|
ttsModel: TextToSpeechModel
|
||||||
/** Keep a running personal-WeChat OneBot process across app restarts. */
|
|
||||||
keepPersonalWechatProcess?: boolean
|
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
/**
|
/**
|
||||||
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
|
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
|
||||||
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
|
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
|
||||||
@@ -130,8 +129,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
showStartupProgress: true,
|
showStartupProgress: true,
|
||||||
ttsSelectedVoiceId: '',
|
ttsSelectedVoiceId: '',
|
||||||
ttsModel: 's2.1-pro-free',
|
ttsModel: 's2.1-pro-free',
|
||||||
keepPersonalWechatProcess: false,
|
|
||||||
windowsWechatPort: '',
|
windowsWechatPort: '',
|
||||||
|
reportImagePostfixText: '今日日报',
|
||||||
queryAgentEnabled: true
|
queryAgentEnabled: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +149,10 @@ export function loadSettings(): AppSettings {
|
|||||||
if (cache) return cache
|
if (cache) return cache
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(SETTINGS_FILE)) {
|
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 }
|
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||||
if (raw.autoLogin === undefined) {
|
if (raw.autoLogin === undefined) {
|
||||||
const hasSavedDatabaseKey = fs.existsSync(
|
const hasSavedDatabaseKey = fs.existsSync(
|
||||||
@@ -176,7 +178,7 @@ export function loadSettings(): AppSettings {
|
|||||||
}
|
}
|
||||||
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
|
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
|
||||||
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
|
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
|
||||||
if (cache.recallProtectionEnabled) {
|
if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) {
|
||||||
cache.recallProtectionEnabled = false
|
cache.recallProtectionEnabled = false
|
||||||
saveSettings(cache)
|
saveSettings(cache)
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,11 @@ export function loadSettings(): AppSettings {
|
|||||||
export function saveSettings(next: AppSettings): AppSettings {
|
export function saveSettings(next: AppSettings): AppSettings {
|
||||||
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
|
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
|
||||||
// 避免遗留入口或旧版本把它重新打开。
|
// 避免遗留入口或旧版本把它重新打开。
|
||||||
cache = { ...next, recallProtectionEnabled: false }
|
const sanitized = { ...next } as AppSettings & {
|
||||||
|
keepPersonalWechatProcess?: unknown
|
||||||
|
}
|
||||||
|
delete sanitized.keepPersonalWechatProcess
|
||||||
|
cache = { ...sanitized, recallProtectionEnabled: false }
|
||||||
try {
|
try {
|
||||||
ensureDir()
|
ensureDir()
|
||||||
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import path from 'path'
|
|||||||
import type {
|
import type {
|
||||||
PersonalWechatSendCapability,
|
PersonalWechatSendCapability,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult
|
PersonalWechatSendResult,
|
||||||
|
PersonalWechatSenderStatus
|
||||||
} from '../../shared/personal-wechat'
|
} from '../../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
PolicyDecision,
|
PolicyDecision,
|
||||||
WechatActionAuditRecord,
|
WechatActionAuditRecord,
|
||||||
WechatActionContent,
|
WechatActionContent,
|
||||||
WechatActionErrorCode,
|
WechatActionErrorCode,
|
||||||
WechatActionMemberEventReference,
|
|
||||||
WechatActionRequest,
|
WechatActionRequest,
|
||||||
WechatActionResult
|
WechatActionResult
|
||||||
} from '../../shared/wechat-action'
|
} from '../../shared/wechat-action'
|
||||||
@@ -22,17 +22,40 @@ import { wechatSendGateway } from './wechat-send-gateway'
|
|||||||
const MAX_AUDIT_RECORDS = 500
|
const MAX_AUDIT_RECORDS = 500
|
||||||
const MAX_CONTENT_PREVIEW_LENGTH = 240
|
const MAX_CONTENT_PREVIEW_LENGTH = 240
|
||||||
export const AUTOMATION_SEND_INTERVAL_MS = 3_000
|
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 {
|
export interface WechatActionGatewayDependencies {
|
||||||
getCapability?: () => Promise<PersonalWechatSendCapability>
|
getCapability?: () => Promise<PersonalWechatSendCapability>
|
||||||
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
||||||
getMemberEvent?: (
|
|
||||||
sourceId: string
|
|
||||||
) =>
|
|
||||||
| WechatActionMemberEventReference
|
|
||||||
| Promise<WechatActionMemberEventReference | undefined>
|
|
||||||
| undefined
|
|
||||||
getUserDataPath?: () => string
|
getUserDataPath?: () => string
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
wait?: (milliseconds: number) => Promise<void>
|
wait?: (milliseconds: number) => Promise<void>
|
||||||
@@ -43,10 +66,6 @@ interface LoadedAuditState {
|
|||||||
records: WechatActionAuditRecord[]
|
records: WechatActionAuditRecord[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WechatActionPolicyContext {
|
|
||||||
memberEvent?: WechatActionMemberEventReference
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultDependencies = (): Required<
|
const defaultDependencies = (): Required<
|
||||||
Pick<
|
Pick<
|
||||||
WechatActionGatewayDependencies,
|
WechatActionGatewayDependencies,
|
||||||
@@ -76,7 +95,6 @@ export class WechatActionGateway {
|
|||||||
WechatActionGatewayDependencies,
|
WechatActionGatewayDependencies,
|
||||||
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
|
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
|
||||||
>
|
>
|
||||||
private readonly memberEvents = new Map<string, WechatActionMemberEventReference>()
|
|
||||||
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
|
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
|
||||||
private auditState: LoadedAuditState | null = null
|
private auditState: LoadedAuditState | null = null
|
||||||
private automationSendTail: Promise<void> = Promise.resolve()
|
private automationSendTail: Promise<void> = Promise.resolve()
|
||||||
@@ -86,20 +104,33 @@ export class WechatActionGateway {
|
|||||||
this.deps = { ...defaultDependencies(), ...deps }
|
this.deps = { ...defaultDependencies(), ...deps }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 记录退群事件,发送通知前可确认事件所属群聊。 */
|
/**
|
||||||
registerMemberEvent(event: WechatActionMemberEventReference): void {
|
* 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔;
|
||||||
const id = String(event?.id || '').trim()
|
* 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。
|
||||||
const roomId = String(event?.roomId || '').trim()
|
*/
|
||||||
if (!id || !roomId) return
|
async executeReportImageSequence(
|
||||||
this.memberEvents.set(id, { id, roomId })
|
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 {
|
const postfixText = String(request.postfixText || '').trim()
|
||||||
for (const event of events) this.registerMemberEvent(event)
|
if (!postfixText) return { image }
|
||||||
}
|
|
||||||
|
|
||||||
clearMemberEvents(): void {
|
const postfix = await this.execute({
|
||||||
this.memberEvents.clear()
|
origin: 'user_manual',
|
||||||
|
purpose: 'manual_report_postfix',
|
||||||
|
triggerType: 'automation',
|
||||||
|
recipient: request.recipient,
|
||||||
|
content: { type: 'text', text: postfixText }
|
||||||
|
})
|
||||||
|
return { image, postfix }
|
||||||
}
|
}
|
||||||
|
|
||||||
listAuditRecords(): WechatActionAuditRecord[] {
|
listAuditRecords(): WechatActionAuditRecord[] {
|
||||||
@@ -145,8 +176,7 @@ export class WechatActionGateway {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventContext = await this.resolveEventContext(request)
|
const policy = evaluateWechatActionPolicy(request)
|
||||||
const policy = evaluateWechatActionPolicy(request, eventContext)
|
|
||||||
if (policy.decision !== 'allow') {
|
if (policy.decision !== 'allow') {
|
||||||
return this.finishBlocked(
|
return this.finishBlocked(
|
||||||
actionId,
|
actionId,
|
||||||
@@ -240,28 +270,6 @@ export class WechatActionGateway {
|
|||||||
return queued
|
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> {
|
private async findExisting(idempotencyKey: string): Promise<WechatActionResult | undefined> {
|
||||||
const state = this.loadAuditState()
|
const state = this.loadAuditState()
|
||||||
const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey)
|
const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey)
|
||||||
@@ -280,9 +288,6 @@ export class WechatActionGateway {
|
|||||||
private idempotencyKey(request: WechatActionRequest): string | undefined {
|
private idempotencyKey(request: WechatActionRequest): string | undefined {
|
||||||
const explicit = String(request.idempotencyKey || '').trim()
|
const explicit = String(request.idempotencyKey || '').trim()
|
||||||
if (explicit) return explicit
|
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) {
|
if (request.origin === 'scheduled_report' && request.executionId) {
|
||||||
return `scheduled_report:${request.executionId}`
|
return `scheduled_report:${request.executionId}`
|
||||||
}
|
}
|
||||||
@@ -429,10 +434,7 @@ export class WechatActionGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evaluateWechatActionPolicy(
|
export function evaluateWechatActionPolicy(request: WechatActionRequest): PolicyDecision {
|
||||||
request: WechatActionRequest,
|
|
||||||
context: WechatActionPolicyContext = {}
|
|
||||||
): PolicyDecision {
|
|
||||||
if (!request || typeof request !== 'object') {
|
if (!request || typeof request !== 'object') {
|
||||||
return {
|
return {
|
||||||
decision: 'block',
|
decision: 'block',
|
||||||
@@ -463,29 +465,6 @@ export function evaluateWechatActionPolicy(
|
|||||||
reason: `自动化动作不允许执行 purpose=${request.purpose}`
|
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' }
|
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(
|
function contentAudit(
|
||||||
content: WechatActionContent
|
content: WechatActionContent
|
||||||
): Pick<WechatActionAuditRecord, 'contentPreview' | 'contentHash'> {
|
): Pick<WechatActionAuditRecord, 'contentPreview' | 'contentHash'> {
|
||||||
|
|||||||
@@ -126,54 +126,5 @@ export class AudioDecoderRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry {
|
export function createDefaultAudioDecoderRegistry(): AudioDecoderRegistry {
|
||||||
return new AudioDecoderRegistry()
|
return new AudioDecoderRegistry().register(new SilkAudioDecoder())
|
||||||
.register(new SilkAudioDecoder())
|
|
||||||
.register(new AmrAudioDecoder())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V3 AMR:外部 `ffmpeg` 解码为 PCM(MultiMediaDyn `CAMRDecoder` 未导出)。
|
|
||||||
*/
|
|
||||||
export class AmrAudioDecoder implements VoiceAudioDecoder {
|
|
||||||
readonly codec = 'amr'
|
|
||||||
|
|
||||||
async decode(source: EncodedVoiceSource): Promise<DecodedVoiceAudio> {
|
|
||||||
const { spawn } = await import('child_process')
|
|
||||||
const { tmpdir } = await import('os')
|
|
||||||
const { promises: fs } = await import('fs')
|
|
||||||
const { randomBytes } = await import('crypto')
|
|
||||||
const stamp = randomBytes(8).toString('hex')
|
|
||||||
const inPath = join(tmpdir(), `tracememo-amr-in-${stamp}.amr`)
|
|
||||||
const outPath = join(tmpdir(), `tracememo-amr-out-${stamp}.wav`)
|
|
||||||
await fs.writeFile(inPath, source.data)
|
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const child = spawn(
|
|
||||||
'ffmpeg',
|
|
||||||
['-y', '-i', inPath, '-f', 'wav', '-acodec', 'pcm_s16le', '-ar', '8000', '-ac', '1', outPath],
|
|
||||||
{ stdio: ['ignore', 'ignore', 'pipe'] }
|
|
||||||
)
|
|
||||||
let err = ''
|
|
||||||
child.stderr.on('data', (chunk) => {
|
|
||||||
err += String(chunk)
|
|
||||||
})
|
|
||||||
child.on('error', reject)
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (code === 0) resolve()
|
|
||||||
else reject(new Error(`ffmpeg amr decode failed: ${err.slice(-200)}`))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const wav = await fs.readFile(outPath)
|
|
||||||
const pcm = wav.length > 44 && wav.toString('ascii', 0, 4) === 'RIFF' ? wav.subarray(44) : wav
|
|
||||||
return {
|
|
||||||
pcm,
|
|
||||||
sampleRate: 8000,
|
|
||||||
channels: 1,
|
|
||||||
sourceHash: source.sourceHash
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await fs.rm(inPath, { force: true })
|
|
||||||
await fs.rm(outPath, { force: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export interface VoiceSilkMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the exact PCM contract consumed by the bundled OneBot encoder.
|
* Validate the exact PCM contract consumed by the bundled SILK encoder.
|
||||||
* The Go Silk encoder emits a header-only payload for sub-frame input, which
|
* The encoder emits a header-only payload for sub-frame input, which would
|
||||||
* is accepted by the old path but produces an unplayable WeChat voice.
|
* produce an unplayable WeChat voice.
|
||||||
*/
|
*/
|
||||||
export function validateVoicePcm(
|
export function validateVoicePcm(
|
||||||
pcm: Uint8Array,
|
pcm: Uint8Array,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { promises as fs } from 'fs'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
import {
|
import {
|
||||||
createDefaultAudioDecoderRegistry,
|
createDefaultAudioDecoderRegistry,
|
||||||
@@ -213,17 +211,6 @@ export class VoiceService {
|
|||||||
svrId || 0
|
svrId || 0
|
||||||
)
|
)
|
||||||
if (!voiceResult.success || !voiceResult.hex) {
|
if (!voiceResult.success || !voiceResult.hex) {
|
||||||
const disk = await this.resolveFromDisk(sessionId, localId, createTime, svrId)
|
|
||||||
if (disk?.length) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
source: {
|
|
||||||
data: disk,
|
|
||||||
codec: 'silk',
|
|
||||||
sourceHash: createHash('sha256').update(disk).digest('hex')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: false, error: voiceResult.error || '获取语音数据失败' }
|
return { success: false, error: voiceResult.error || '获取语音数据失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,38 +239,6 @@ export class VoiceService {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* V2:旁路 `wcdb_get_voice_data`,在账号目录下找语音文件
|
|
||||||
* (`GetMsgAudioPath` 同域:Message / MsgAndFiles / VoiceTemp)。
|
|
||||||
*/
|
|
||||||
private async resolveFromDisk(
|
|
||||||
sessionId: string,
|
|
||||||
localId: number,
|
|
||||||
createTime: number,
|
|
||||||
svrId?: string | number
|
|
||||||
): Promise<Buffer | null> {
|
|
||||||
const accountRoot = this.wcdb4Client.getAccountRoot?.()
|
|
||||||
if (!accountRoot) return null
|
|
||||||
const needles = [
|
|
||||||
String(localId),
|
|
||||||
String(createTime),
|
|
||||||
svrId !== undefined && svrId !== null && String(svrId) !== '0' ? String(svrId) : '',
|
|
||||||
sessionId.replace(/@chatroom$/, '')
|
|
||||||
].filter(Boolean)
|
|
||||||
const files = await listVoiceDiskCandidates(accountRoot)
|
|
||||||
for (const file of files) {
|
|
||||||
const name = file.toLowerCase()
|
|
||||||
if (!needles.some((needle) => needle && name.includes(needle.toLowerCase()))) continue
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(file)
|
|
||||||
if (data.length) return data
|
|
||||||
} catch {
|
|
||||||
// try next candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeVoiceBlob(hex: string): Buffer | null {
|
private decodeVoiceBlob(hex: string): Buffer | null {
|
||||||
try {
|
try {
|
||||||
const hexClean = hex.replace(/\s+/g, '')
|
const hexClean = hex.replace(/\s+/g, '')
|
||||||
@@ -319,51 +274,3 @@ export class VoiceService {
|
|||||||
return Buffer.concat([header, pcmData])
|
return Buffer.concat([header, pcmData])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const VOICE_DISK_EXTS = new Set(['.aud', '.silk', '.amr'])
|
|
||||||
const VOICE_DISK_DIRS = [
|
|
||||||
'msg/Message',
|
|
||||||
'msg/MsgAndFiles',
|
|
||||||
'msg/VoiceTemp',
|
|
||||||
'msg/History',
|
|
||||||
'Message',
|
|
||||||
'MsgAndFiles',
|
|
||||||
'VoiceTemp',
|
|
||||||
// 真机布局(2026-09-24):cache/YYYY-MM/Message/<md5>/VoiceTemp/
|
|
||||||
'cache'
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath / VoiceTemp 对齐)。 */
|
|
||||||
export async function listVoiceDiskCandidates(accountRoot: string): Promise<string[]> {
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
||||||
if (out.length > 200 || depth > 6) return
|
|
||||||
let names: string[]
|
|
||||||
try {
|
|
||||||
names = await fs.readdir(dir)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const name of names) {
|
|
||||||
const full = join(dir, name)
|
|
||||||
let stat: Awaited<ReturnType<typeof fs.stat>>
|
|
||||||
try {
|
|
||||||
stat = await fs.stat(full)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
await walk(full, depth + 1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const ext = name.slice(name.lastIndexOf('.')).toLowerCase()
|
|
||||||
if (!VOICE_DISK_EXTS.has(ext)) continue
|
|
||||||
out.push(full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const rel of VOICE_DISK_DIRS) {
|
|
||||||
await walk(join(accountRoot, rel), 0)
|
|
||||||
if (out.length > 200) break
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
+76
-111
@@ -5,51 +5,6 @@ import crypto from 'crypto'
|
|||||||
import { createRequire } from 'module'
|
import { createRequire } from 'module'
|
||||||
import { createConnection, Socket } from 'net'
|
import { createConnection, Socket } from 'net'
|
||||||
import { getResourceRoots } from './resource-paths'
|
import { getResourceRoots } from './resource-paths'
|
||||||
|
|
||||||
export type RoomInfoRow = {
|
|
||||||
roomId: string
|
|
||||||
owner?: string
|
|
||||||
announcement?: string
|
|
||||||
announcementEditor?: string
|
|
||||||
maxMemberCount?: number
|
|
||||||
chatName?: string
|
|
||||||
openImAccountType?: string
|
|
||||||
isOpenIm?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickString(row: Record<string, unknown>, keys: string[]): string | undefined {
|
|
||||||
for (const key of keys) {
|
|
||||||
const value = row[key]
|
|
||||||
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeRoomInfoRow(chatroomId: string, row: Record<string, unknown>): RoomInfoRow {
|
|
||||||
const openImAccountType =
|
|
||||||
pickString(row, ['openim_acct_type', 'openimAcctType', 'openim_type']) || undefined
|
|
||||||
const maxRaw = pickString(row, ['max_member_count', 'maxMemberCount', 'member_max'])
|
|
||||||
return {
|
|
||||||
roomId: chatroomId,
|
|
||||||
owner: pickString(row, ['roomowner', 'room_owner', 'owner', 'owner_id']),
|
|
||||||
announcement: pickString(row, [
|
|
||||||
'textannouncement',
|
|
||||||
'announcement',
|
|
||||||
'announcement_',
|
|
||||||
'chatroomannouncement'
|
|
||||||
]),
|
|
||||||
announcementEditor: pickString(row, [
|
|
||||||
'announcement_editor',
|
|
||||||
'announcement_editor_',
|
|
||||||
'announcementeditor'
|
|
||||||
]),
|
|
||||||
maxMemberCount: maxRaw && Number.isFinite(Number(maxRaw)) ? Number(maxRaw) : undefined,
|
|
||||||
chatName: pickString(row, ['chatroomnick', 'chat_name', 'chatroom_name', 'displayname']),
|
|
||||||
openImAccountType,
|
|
||||||
isOpenIm: openImAccountType ? openImAccountType !== '0' && openImAccountType !== '' : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
import { wcdbDebugLog } from './wcdb-debug'
|
import { wcdbDebugLog } from './wcdb-debug'
|
||||||
import type { ImageMessageCountProbe } from '../shared/image-text-index'
|
import type { ImageMessageCountProbe } from '../shared/image-text-index'
|
||||||
import { imageTextWindowToSeconds } from '../shared/image-text-index'
|
import { imageTextWindowToSeconds } from '../shared/image-text-index'
|
||||||
@@ -429,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 {
|
export class Wcdb4Client {
|
||||||
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
|
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
|
||||||
|
|
||||||
@@ -540,7 +568,7 @@ export class Wcdb4Client {
|
|||||||
private wcdbStopMonitorPipe: (() => void) | null = null
|
private wcdbStopMonitorPipe: (() => void) | null = null
|
||||||
private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null
|
private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null
|
||||||
private monitorPipeClient: Socket | 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 monitorConnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private monitorReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
private monitorReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private monitorPipePath = ''
|
private monitorPipePath = ''
|
||||||
@@ -818,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) {
|
if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -956,13 +984,7 @@ export class Wcdb4Client {
|
|||||||
private emitMonitorPayload(rawPayload: string): void {
|
private emitMonitorPayload(rawPayload: string): void {
|
||||||
const payload = rawPayload.trim()
|
const payload = rawPayload.trim()
|
||||||
if (!payload || !this.monitorCallback) return
|
if (!payload || !this.monitorCallback) return
|
||||||
|
this.monitorCallback(parseMonitorEvent(payload))
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(payload) as { action?: string }
|
|
||||||
this.monitorCallback(parsed.action || 'update', payload)
|
|
||||||
} catch {
|
|
||||||
this.monitorCallback('update', payload)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleMonitorReconnect(): void {
|
private scheduleMonitorReconnect(): void {
|
||||||
@@ -2723,63 +2745,6 @@ export class Wcdb4Client {
|
|||||||
return this.accountRoot
|
return this.accountRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 只读 roominfo(contact.db / chatroom 或兼容列名)。
|
|
||||||
*/
|
|
||||||
getRoomInfo(chatroomId: string): RoomInfoRow | null {
|
|
||||||
if (!this.wcdbExecQuery || !chatroomId.endsWith('@chatroom')) return null
|
|
||||||
const escaped = chatroomId.replace(/'/g, "''")
|
|
||||||
const sql = `SELECT * FROM chatroom WHERE chatroomname = '${escaped}' LIMIT 1`
|
|
||||||
try {
|
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
|
||||||
this.wcdbExecQuery!(handle, 'contact', '', sql, outJson)
|
|
||||||
)
|
|
||||||
return rows?.[0] ? normalizeRoomInfoRow(chatroomId, rows[0]) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRoomInfoAsync(chatroomId: string): Promise<RoomInfoRow | null> {
|
|
||||||
if (!this.wcdbExecQuery || !chatroomId.endsWith('@chatroom')) return null
|
|
||||||
const escaped = chatroomId.replace(/'/g, "''")
|
|
||||||
const sql = `SELECT * FROM chatroom WHERE chatroomname = '${escaped}' LIMIT 1`
|
|
||||||
try {
|
|
||||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
|
||||||
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
|
|
||||||
'contact',
|
|
||||||
'',
|
|
||||||
sql
|
|
||||||
)
|
|
||||||
return rows?.[0] ? normalizeRoomInfoRow(chatroomId, rows[0]) : null
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WCDB4] getRoomInfoAsync failed:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 只读列收藏(favorite.db / fav_db_item),供导出与搜索。 */
|
|
||||||
async listFavoriteItems(limit = 200): Promise<Record<string, unknown>[]> {
|
|
||||||
if (!this.wcdbExecQuery) return []
|
|
||||||
const sql = `SELECT local_id, server_id, type, update_time, content, fromusr, realchatname FROM fav_db_item ORDER BY update_time DESC LIMIT ${Math.max(
|
|
||||||
1,
|
|
||||||
Math.min(1000, limit)
|
|
||||||
)}`
|
|
||||||
try {
|
|
||||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
|
||||||
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
|
|
||||||
'favorite',
|
|
||||||
path.join(this.accountRoot, 'db_storage/favorite/favorite.db'),
|
|
||||||
sql
|
|
||||||
)
|
|
||||||
return Array.isArray(rows) ? rows : []
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WCDB4] listFavoriteItems failed:', error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getKey(): string {
|
getKey(): string {
|
||||||
return this.key
|
return this.key
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+53
-89
@@ -82,26 +82,28 @@ import type {
|
|||||||
PersonalWechatVoiceDiagnostic
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||||
|
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification,
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
ScheduledReportNotificationSettings,
|
ScheduledReportNotificationSettings,
|
||||||
ScheduledReportNotificationSettingsResult,
|
ScheduledReportNotificationSettingsResult,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult
|
||||||
ScheduledReportTask,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeDownloadResult,
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../shared/personal-wechat-runtime'
|
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatVoiceEncodingEnvironment,
|
PersonalWechatVoiceEncodingEnvironment,
|
||||||
PersonalWechatVoiceEncodingEnvironmentResult
|
PersonalWechatVoiceEncodingEnvironmentResult
|
||||||
} from '../shared/personal-wechat-voice-runtime'
|
} from '../shared/personal-wechat-voice-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
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 { ActionLogEntry } from '../shared/action-log'
|
||||||
import type {
|
import type {
|
||||||
AppUpdateCheckResult,
|
AppUpdateCheckResult,
|
||||||
@@ -155,37 +157,6 @@ import type {
|
|||||||
TextToSpeechSettingsResult
|
TextToSpeechSettingsResult
|
||||||
} from '../shared/text-to-speech'
|
} from '../shared/text-to-speech'
|
||||||
|
|
||||||
export type TransferPaymentInfo = {
|
|
||||||
paySubtype?: string
|
|
||||||
amountText?: string
|
|
||||||
transcationId?: string
|
|
||||||
transferId?: string
|
|
||||||
invalidTime?: string
|
|
||||||
beginTransferTime?: string
|
|
||||||
effectiveDate?: string
|
|
||||||
payMemo?: string
|
|
||||||
receiverUsername?: string
|
|
||||||
payerUsername?: string
|
|
||||||
transferStatus?: string
|
|
||||||
transferStatusText?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RedPacketPaymentInfo = {
|
|
||||||
templateId?: string
|
|
||||||
receiveTitle?: string
|
|
||||||
sendTitle?: string
|
|
||||||
sceneText?: string
|
|
||||||
senderDes?: string
|
|
||||||
receiverDes?: string
|
|
||||||
iconUrl?: string
|
|
||||||
nativeUrl?: string
|
|
||||||
sendId?: string
|
|
||||||
hbType?: string
|
|
||||||
hbStatus?: string
|
|
||||||
receiveStatus?: string
|
|
||||||
redPacketStatusText?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
| { type: 'voice'; duration?: number }
|
| { type: 'voice'; duration?: number }
|
||||||
@@ -198,7 +169,6 @@ export type ParsedContent =
|
|||||||
url: string
|
url: string
|
||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
transfer?: TransferPaymentInfo
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'miniProgram'
|
type: 'miniProgram'
|
||||||
@@ -210,13 +180,7 @@ export type ParsedContent =
|
|||||||
thumbDatName?: string
|
thumbDatName?: string
|
||||||
thumbDataUrl?: string
|
thumbDataUrl?: string
|
||||||
}
|
}
|
||||||
| {
|
| { type: 'redPacket'; title: string; description?: string; url?: string }
|
||||||
type: 'redPacket'
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
url?: string
|
|
||||||
pay?: RedPacketPaymentInfo
|
|
||||||
}
|
|
||||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
||||||
| {
|
| {
|
||||||
@@ -331,18 +295,37 @@ declare global {
|
|||||||
avatar: string
|
avatar: string
|
||||||
}[]
|
}[]
|
||||||
} | null>
|
} | null>
|
||||||
|
getGroupMemberStats: (request: GroupMemberStatsQuery) => Promise<GroupMemberStatsResult>
|
||||||
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
|
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
|
||||||
|
listGroupExitMonitorEvents: (query?: {
|
||||||
|
roomId?: string
|
||||||
|
sinceMs?: number
|
||||||
|
untilMs?: number
|
||||||
|
limit?: number
|
||||||
|
}) => Promise<GroupExitMonitorEvent[]>
|
||||||
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
|
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
|
||||||
setGroupExitMonitorGroups: (
|
/** 保存**监控范围**。通知配置已迁到「自动化 → 退群通知」。 */
|
||||||
roomIds: string[],
|
setGroupExitMonitorGroups: (roomIds: string[]) => Promise<GroupExitMonitorState>
|
||||||
notificationRoomIds?: string[]
|
|
||||||
) => Promise<GroupExitMonitorState>
|
|
||||||
setGroupExitMonitorNotificationTemplate: (template: string) => Promise<GroupExitMonitorState>
|
|
||||||
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
|
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
|
||||||
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
|
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
|
||||||
resendGroupExitMonitorEvent: (eventId: string) => Promise<GroupExitMonitorState>
|
|
||||||
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
|
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
|
||||||
listWechatActionLogs: () => Promise<ActionLogEntry[]>
|
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
|
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
|
||||||
search: (keyword: string) => Promise<string | null>
|
search: (keyword: string) => Promise<string | null>
|
||||||
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
||||||
@@ -559,6 +542,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -599,6 +583,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -622,6 +607,7 @@ declare global {
|
|||||||
ttsSelectedVoiceId: string
|
ttsSelectedVoiceId: string
|
||||||
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
ttsModel: import('../shared/text-to-speech').TextToSpeechModel
|
||||||
windowsWechatPort: string
|
windowsWechatPort: string
|
||||||
|
reportImagePostfixText: string
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}>
|
}>
|
||||||
@@ -735,21 +721,11 @@ declare global {
|
|||||||
onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void
|
onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void
|
||||||
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
||||||
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
||||||
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
|
|
||||||
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
|
|
||||||
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
|
checkPersonalWechatSenderStatus: (port?: string) => Promise<PersonalWechatSenderStatus>
|
||||||
checkPersonalWechatVoiceEncodingEnvironment: () => Promise<PersonalWechatVoiceEncodingEnvironment>
|
checkPersonalWechatVoiceEncodingEnvironment: () => Promise<PersonalWechatVoiceEncodingEnvironment>
|
||||||
installPersonalWechatPilk: () => Promise<PersonalWechatVoiceEncodingEnvironmentResult>
|
installPersonalWechatPilk: () => Promise<PersonalWechatVoiceEncodingEnvironmentResult>
|
||||||
openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }>
|
openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }>
|
||||||
openPersonalWechatVoiceFfmpegDownload: () => 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>
|
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
|
||||||
sendGeneratedTtsVoice: (
|
sendGeneratedTtsVoice: (
|
||||||
request: PersonalWechatGeneratedTtsVoiceRequest
|
request: PersonalWechatGeneratedTtsVoiceRequest
|
||||||
@@ -757,35 +733,23 @@ declare global {
|
|||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
) => Promise<PersonalWechatSendResult>
|
) => Promise<PersonalWechatSendResult>
|
||||||
listScheduledReports: () => Promise<ScheduledReportTask[]>
|
/** 定时日报:「立即执行」——与 scheduler 走同一条执行链路(manual trigger)。 */
|
||||||
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
|
runScheduledReportRule: (
|
||||||
|
ruleId: string
|
||||||
|
) => Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>
|
||||||
|
/** 定时日报:旧执行记录**只读存档**(旧记录无法无损转换,原样展示)。 */
|
||||||
|
listScheduledReportLegacyExecutions: (
|
||||||
|
ruleId?: string
|
||||||
|
) => Promise<ScheduledReportExecution[]>
|
||||||
|
/** 定时日报:微信异常通知开关与能力检测。 */
|
||||||
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
|
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
|
||||||
|
getScheduledReportNotificationCapability: () => Promise<ScheduledReportNotificationCapability>
|
||||||
setScheduledReportNotificationEnabled: (
|
setScheduledReportNotificationEnabled: (
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
) => Promise<ScheduledReportNotificationSettingsResult>
|
) => 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: (
|
testScheduledReportErrorNotification: (
|
||||||
taskId: string
|
ruleId: string
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
) => Promise<ScheduledReportResult<ScheduledReportNotification>>
|
||||||
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
||||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||||
|
|||||||
+72
-78
@@ -52,26 +52,28 @@ import type {
|
|||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification,
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
ScheduledReportNotificationSettings,
|
ScheduledReportNotificationSettings,
|
||||||
ScheduledReportNotificationSettingsResult,
|
ScheduledReportNotificationSettingsResult,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult
|
||||||
ScheduledReportTask,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeDownloadResult,
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../shared/personal-wechat-runtime'
|
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatVoiceEncodingEnvironment,
|
PersonalWechatVoiceEncodingEnvironment,
|
||||||
PersonalWechatVoiceEncodingEnvironmentResult
|
PersonalWechatVoiceEncodingEnvironmentResult
|
||||||
} from '../shared/personal-wechat-voice-runtime'
|
} from '../shared/personal-wechat-voice-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import type { AppUpdateState } from '../shared/app-update'
|
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 { ActionLogEntry } from '../shared/action-log'
|
||||||
import type { CacheClearScope, CacheSummary } from '../shared/cache'
|
import type { CacheClearScope, CacheSummary } from '../shared/cache'
|
||||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||||
@@ -167,29 +169,59 @@ const api = {
|
|||||||
): Promise<MessagesAroundResult> =>
|
): Promise<MessagesAroundResult> =>
|
||||||
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
|
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
|
||||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||||
|
getGroupMemberStats: (request: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> =>
|
||||||
|
ipcRenderer.invoke('group-stats:getMemberStats', request),
|
||||||
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
|
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:getState'),
|
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> =>
|
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
|
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
|
||||||
setGroupExitMonitorGroups: (
|
/** 保存**监控范围**。通知配置已迁到自动化规则,这里不再有第二个参数。 */
|
||||||
roomIds: string[],
|
setGroupExitMonitorGroups: (roomIds: string[]): Promise<GroupExitMonitorState> =>
|
||||||
notificationRoomIds?: string[]
|
ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds),
|
||||||
): Promise<GroupExitMonitorState> =>
|
|
||||||
notificationRoomIds === undefined
|
|
||||||
? ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds)
|
|
||||||
: ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds, notificationRoomIds),
|
|
||||||
setGroupExitMonitorNotificationTemplate: (template: string): Promise<GroupExitMonitorState> =>
|
|
||||||
ipcRenderer.invoke('group-exit-monitor:setTemplate', template),
|
|
||||||
checkGroupExitMonitorNow: (): Promise<GroupExitMonitorState> =>
|
checkGroupExitMonitorNow: (): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:checkNow'),
|
ipcRenderer.invoke('group-exit-monitor:checkNow'),
|
||||||
clearGroupExitMonitorEvents: (): Promise<GroupExitMonitorState> =>
|
clearGroupExitMonitorEvents: (): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
|
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
|
||||||
resendGroupExitMonitorEvent: (eventId: string): Promise<GroupExitMonitorState> =>
|
|
||||||
ipcRenderer.invoke('group-exit-monitor:resendEvent', eventId),
|
|
||||||
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
|
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
|
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
|
||||||
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
|
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
|
||||||
ipcRenderer.invoke('wechat-action-log:list'),
|
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) => {
|
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
|
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
|
||||||
callback(state)
|
callback(state)
|
||||||
@@ -519,10 +551,6 @@ const api = {
|
|||||||
ipcRenderer.invoke('wechat-personal:getStatus'),
|
ipcRenderer.invoke('wechat-personal:getStatus'),
|
||||||
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
|
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getSendCapability'),
|
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> =>
|
checkPersonalWechatSenderStatus: (port?: string): Promise<PersonalWechatSenderStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:checkStatus', port),
|
ipcRenderer.invoke('wechat-personal:checkStatus', port),
|
||||||
checkPersonalWechatVoiceEncodingEnvironment:
|
checkPersonalWechatVoiceEncodingEnvironment:
|
||||||
@@ -534,26 +562,6 @@ const api = {
|
|||||||
ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'),
|
ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'),
|
||||||
openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> =>
|
openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> =>
|
||||||
ipcRenderer.invoke('wechat-personal:openVoiceFfmpegDownload'),
|
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> =>
|
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:rebind'),
|
ipcRenderer.invoke('wechat-personal:rebind'),
|
||||||
sendGeneratedTtsVoice: (
|
sendGeneratedTtsVoice: (
|
||||||
@@ -564,44 +572,30 @@ const api = {
|
|||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||||
listScheduledReports: (): Promise<ScheduledReportTask[]> =>
|
/**
|
||||||
ipcRenderer.invoke('scheduled-report:list'),
|
* 定时日报(Automation 的 `scheduled_report` 规则类型)。
|
||||||
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
*
|
||||||
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
|
* 规则 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> =>
|
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
|
||||||
ipcRenderer.invoke('scheduled-report:getNotificationSettings'),
|
ipcRenderer.invoke('automation:getScheduledReportNotificationSettings'),
|
||||||
|
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
|
||||||
|
ipcRenderer.invoke('automation:getScheduledReportNotificationCapability'),
|
||||||
setScheduledReportNotificationEnabled: (
|
setScheduledReportNotificationEnabled: (
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
): Promise<ScheduledReportNotificationSettingsResult> =>
|
): Promise<ScheduledReportNotificationSettingsResult> =>
|
||||||
ipcRenderer.invoke('scheduled-report:setNotificationEnabled', enabled),
|
ipcRenderer.invoke('automation:setScheduledReportNotificationEnabled', enabled),
|
||||||
createScheduledReport: (
|
|
||||||
request: ScheduledReportCreateInput
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:create', request),
|
|
||||||
updateScheduledReport: (
|
|
||||||
taskId: string,
|
|
||||||
request: ScheduledReportUpdateInput
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:update', taskId, request),
|
|
||||||
deleteScheduledReport: (taskId: string): Promise<ScheduledReportResult<{ deletedId: string }>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:delete', taskId),
|
|
||||||
setScheduledReportEnabled: (
|
|
||||||
taskId: string,
|
|
||||||
enabled: boolean
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:setEnabled', taskId, enabled),
|
|
||||||
runScheduledReportNow: (
|
|
||||||
taskId: string
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:runNow', taskId),
|
|
||||||
retryScheduledReportSend: (
|
|
||||||
executionId: string
|
|
||||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
|
||||||
ipcRenderer.invoke('scheduled-report:retrySend', executionId),
|
|
||||||
testScheduledReportErrorNotification: (
|
testScheduledReportErrorNotification: (
|
||||||
taskId: string
|
ruleId: string
|
||||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
): Promise<ScheduledReportResult<ScheduledReportNotification>> =>
|
||||||
ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId),
|
ipcRenderer.invoke('automation:testScheduledReportErrorNotification', ruleId),
|
||||||
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
||||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
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 { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
|
||||||
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
||||||
import { ReportViewer } from './components/reports/ReportViewer'
|
import { ReportViewer } from './components/reports/ReportViewer'
|
||||||
import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace'
|
|
||||||
import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace'
|
import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace'
|
||||||
import { contactDisplayName } from './components/reports/types'
|
import { contactDisplayName } from './components/reports/types'
|
||||||
import type { GeneratedReportRecord, ReportWorkspaceView } 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 { enrichQuotedMessages } from './utils/quoted-messages'
|
||||||
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
|
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
|
||||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||||
import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
|
import { runtimePlatform } from './utils/runtime-environment'
|
||||||
import { useToast } from './components/ui'
|
import { Button, useToast } from './components/ui'
|
||||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
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 { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
|
||||||
import {
|
import {
|
||||||
buildContactSearchIndex,
|
buildContactSearchIndex,
|
||||||
@@ -257,6 +257,17 @@ function App(): React.ReactElement {
|
|||||||
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
|
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
|
||||||
const connectionOperationRef = React.useRef(0)
|
const connectionOperationRef = React.useRef(0)
|
||||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
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)
|
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||||
/**
|
/**
|
||||||
* 精确跳转目标(规范化后的消息 id)。
|
* 精确跳转目标(规范化后的消息 id)。
|
||||||
@@ -268,7 +279,7 @@ function App(): React.ReactElement {
|
|||||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
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 [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
||||||
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
||||||
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
||||||
@@ -1639,10 +1650,49 @@ function App(): React.ReactElement {
|
|||||||
setActivePage('settings')
|
setActivePage('settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳到「设置 · 本地索引」。
|
||||||
|
*
|
||||||
|
* 用在群统计这类场景:结果不完整的原因是索引没追平,用户需要一个**可操作的去处**,
|
||||||
|
* 而不是只看到一句「结果可能不完整」却不知道去哪解决。
|
||||||
|
*/
|
||||||
|
const openLocalIndexSettings = (): void => {
|
||||||
|
setSettingsCategory('local-index')
|
||||||
|
setActivePage('settings')
|
||||||
|
}
|
||||||
|
|
||||||
const openAgentHub = (): void => {
|
const openAgentHub = (): void => {
|
||||||
setActivePage('agent-hub')
|
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 => {
|
const dismissFirstUseWelcome = (): void => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
||||||
@@ -1901,12 +1951,12 @@ function App(): React.ReactElement {
|
|||||||
contentFilter={contentFilter}
|
contentFilter={contentFilter}
|
||||||
onContentFilterChange={setContentFilter}
|
onContentFilterChange={setContentFilter}
|
||||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
||||||
onRefreshData={loadContacts}
|
|
||||||
onReloadAvatars={handleReloadCurrentAvatars}
|
onReloadAvatars={handleReloadCurrentAvatars}
|
||||||
onLoadOlderMessages={handleLoadOlderMessages}
|
onLoadOlderMessages={handleLoadOlderMessages}
|
||||||
onCreateGroupReport={handleOpenReportWorkspace}
|
onCreateGroupReport={handleOpenReportWorkspace}
|
||||||
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
||||||
onOpenPersonalWechatSettings={openWechatSendSettings}
|
onOpenPersonalWechatSettings={openWechatSendSettings}
|
||||||
|
onOpenLocalIndexSettings={openLocalIndexSettings}
|
||||||
isAiLoading={reportGeneration.isGenerating}
|
isAiLoading={reportGeneration.isGenerating}
|
||||||
jumpToTime={archiveJumpTime}
|
jumpToTime={archiveJumpTime}
|
||||||
jumpToMessageId={archiveJumpMessageId}
|
jumpToMessageId={archiveJumpMessageId}
|
||||||
@@ -1926,22 +1976,6 @@ function App(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
今日日报
|
今日日报
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -1952,23 +1986,26 @@ function App(): React.ReactElement {
|
|||||||
社区模板市场
|
社区模板市场
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="report-workspace-body">
|
||||||
{reportSection === 'market' ? (
|
{reportSection === 'market' ? (
|
||||||
<ReportTemplateMarketWorkspace
|
<ReportTemplateMarketWorkspace
|
||||||
value={reportGeneration.templateId}
|
value={reportGeneration.templateId}
|
||||||
onChange={reportGeneration.setTemplateId}
|
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' ? (
|
) : reportWorkspaceView === 'result' ? (
|
||||||
<div className="report-center-page">
|
<div className="report-center-page">
|
||||||
<ReportHistorySidebar
|
<ReportHistorySidebar
|
||||||
@@ -2084,6 +2121,20 @@ function App(): React.ReactElement {
|
|||||||
dbReady={isDatabaseConnected}
|
dbReady={isDatabaseConnected}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
onOpenSendSettings={openWechatSendSettings}
|
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':
|
case 'agent-hub':
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { DataTrustBar } from './chat/DataTrustBar'
|
|||||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||||
import { MessageList } from './chat/MessageList'
|
import { MessageList } from './chat/MessageList'
|
||||||
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
|
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
|
||||||
|
import { mergeGroupExitEvents } from '../../../shared/group-exit-event-message'
|
||||||
|
import { useGroupExitEvents } from '../hooks/useGroupExitEvents'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
contact: Contact | null
|
contact: Contact | null
|
||||||
@@ -16,12 +18,13 @@ interface ChatWindowProps {
|
|||||||
contentFilter?: string
|
contentFilter?: string
|
||||||
onContentFilterChange?: (keyword: string) => void
|
onContentFilterChange?: (keyword: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
|
||||||
onReloadAvatars?: () => Promise<void>
|
onReloadAvatars?: () => Promise<void>
|
||||||
onLoadOlderMessages?: () => Promise<void>
|
onLoadOlderMessages?: () => Promise<void>
|
||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
onOpenPersonalWechatSettings?: () => void
|
onOpenPersonalWechatSettings?: () => void
|
||||||
|
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
|
||||||
|
onOpenLocalIndexSettings?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
jumpToTime?: number | null
|
jumpToTime?: number | null
|
||||||
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
|
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
|
||||||
@@ -36,12 +39,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
contentFilter,
|
contentFilter,
|
||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
|
||||||
onReloadAvatars,
|
onReloadAvatars,
|
||||||
onLoadOlderMessages,
|
onLoadOlderMessages,
|
||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
onOpenTextToSpeechSettings,
|
onOpenTextToSpeechSettings,
|
||||||
onOpenPersonalWechatSettings,
|
onOpenPersonalWechatSettings,
|
||||||
|
onOpenLocalIndexSettings,
|
||||||
isAiLoading = false,
|
isAiLoading = false,
|
||||||
jumpToTime,
|
jumpToTime,
|
||||||
jumpToMessageId
|
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(() => {
|
const filteredMessages = React.useMemo(() => {
|
||||||
return messages.filter((msg) => {
|
return allMessages.filter((msg) => {
|
||||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((type) => type.trim())
|
.map((type) => type.trim())
|
||||||
@@ -132,7 +147,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
||||||
return typeMatch && contentMatch
|
return typeMatch && contentMatch
|
||||||
})
|
})
|
||||||
}, [messages, contentFilter])
|
}, [allMessages, contentFilter])
|
||||||
|
|
||||||
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
|
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -165,9 +180,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
isAiLoading={isAiLoading}
|
isAiLoading={isAiLoading}
|
||||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onRefreshData={onRefreshData}
|
|
||||||
onTestSend={() => void handleOpenPersonalWechatSend()}
|
onTestSend={() => void handleOpenPersonalWechatSend()}
|
||||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||||
|
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||||
/>
|
/>
|
||||||
<DataTrustBar messageCount={messages.length} />
|
<DataTrustBar messageCount={messages.length} />
|
||||||
<MessageList
|
<MessageList
|
||||||
|
|||||||
@@ -158,22 +158,7 @@ function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
||||||
const { title, des, url, appname, articles, transfer, typeVal } = data
|
const { title, des, url, appname, articles } = data
|
||||||
|
|
||||||
if (String(typeVal) === '2000' || transfer) {
|
|
||||||
const pay = transfer || {}
|
|
||||||
const meta = [pay.amountText, pay.payMemo ? `备注:${pay.payMemo}` : '', pay.transferStatusText]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')
|
|
||||||
return (
|
|
||||||
<div className="transfer-message">
|
|
||||||
<div className="transfer-kicker">微信转账</div>
|
|
||||||
<div className="transfer-title">{title || '微信转账'}</div>
|
|
||||||
<div className="transfer-meta">{meta || des || '转账消息'}</div>
|
|
||||||
{pay.transferId ? <div className="transfer-footer">单号 {pay.transferId}</div> : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (articles?.length) {
|
if (articles?.length) {
|
||||||
return (
|
return (
|
||||||
@@ -269,8 +254,6 @@ function RedPacketBubble({
|
|||||||
const handleClick = (): void => {
|
const handleClick = (): void => {
|
||||||
if (data.url) window.open(data.url, '_blank')
|
if (data.url) window.open(data.url, '_blank')
|
||||||
}
|
}
|
||||||
const statusText = data.pay?.redPacketStatusText
|
|
||||||
const blurb = data.description || data.pay?.sceneText || '恭喜发财,大吉大利'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -283,7 +266,7 @@ function RedPacketBubble({
|
|||||||
</span>
|
</span>
|
||||||
<span className="red-packet-copy">
|
<span className="red-packet-copy">
|
||||||
<strong>{data.title}</strong>
|
<strong>{data.title}</strong>
|
||||||
<small>{statusText ? `${blurb} · ${statusText}` : blurb}</small>
|
<small>{data.description || '恭喜发财,大吉大利'}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="red-packet-footer">微信红包</div>
|
<div className="red-packet-footer">微信红包</div>
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import {
|
import { Button, IconButton, Tooltip, TooltipContent, TooltipTrigger } from '../ui'
|
||||||
Button,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger
|
|
||||||
} from '../ui'
|
|
||||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
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 { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
||||||
|
import { GroupMemberStatsDialog } from '../group-stats/GroupMemberStatsDialog'
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
contact: Contact
|
contact: Contact
|
||||||
@@ -24,9 +15,10 @@ interface ChatHeaderProps {
|
|||||||
isAiLoading: boolean
|
isAiLoading: boolean
|
||||||
onContentFilterChange: (value: string) => void
|
onContentFilterChange: (value: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
|
||||||
onTestSend: () => void
|
onTestSend: () => void
|
||||||
onOpenAiSettings: () => void
|
onOpenAiSettings: () => void
|
||||||
|
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
|
||||||
|
onOpenLocalIndexSettings?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
@@ -38,11 +30,12 @@ export function ChatHeader({
|
|||||||
isAiLoading,
|
isAiLoading,
|
||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
|
||||||
onTestSend,
|
onTestSend,
|
||||||
onOpenAiSettings
|
onOpenAiSettings,
|
||||||
|
onOpenLocalIndexSettings
|
||||||
}: ChatHeaderProps): React.ReactElement {
|
}: ChatHeaderProps): React.ReactElement {
|
||||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||||
|
const [statsOpen, setStatsOpen] = useState(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const typeLabel = isGroupChat ? '群聊' : '联系人'
|
const typeLabel = isGroupChat ? '群聊' : '联系人'
|
||||||
const visibleCount = contentFilter ? filteredCount : loadedCount
|
const visibleCount = contentFilter ? filteredCount : loadedCount
|
||||||
@@ -91,16 +84,20 @@ export function ChatHeader({
|
|||||||
<IconButton label="刷新聊天记录" variant="ghost" className="h-8 w-8" onClick={onRefresh}>
|
<IconButton label="刷新聊天记录" variant="ghost" className="h-8 w-8" onClick={onRefresh}>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<DropdownMenu>
|
{/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */}
|
||||||
<DropdownMenuTrigger asChild>
|
{isGroupChat ? (
|
||||||
<IconButton label="更多" tooltip="" variant="ghost" className="h-8 w-8">
|
<Button
|
||||||
<MoreIcon />
|
variant="outline"
|
||||||
</IconButton>
|
size="sm"
|
||||||
</DropdownMenuTrigger>
|
className="chat-header-text-action"
|
||||||
<DropdownMenuContent align="end">
|
aria-label="群发言统计"
|
||||||
<DropdownMenuItem onSelect={() => onRefreshData?.()}>刷新数据</DropdownMenuItem>
|
title="群发言统计"
|
||||||
</DropdownMenuContent>
|
onClick={() => setStatsOpen(true)}
|
||||||
</DropdownMenu>
|
>
|
||||||
|
<StatsIcon />
|
||||||
|
<span>群发言统计</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{supportsPersonalWechatSend ? (
|
{supportsPersonalWechatSend ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -148,6 +145,14 @@ export function ChatHeader({
|
|||||||
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
|
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{isGroupChat ? (
|
||||||
|
<GroupMemberStatsDialog
|
||||||
|
open={statsOpen}
|
||||||
|
onOpenChange={setStatsOpen}
|
||||||
|
contact={contact}
|
||||||
|
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</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 React from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import { isGroupExitEventMessage } from '../../../../shared/group-exit-event-message'
|
||||||
|
import { GroupExitEventBubble } from './GroupExitEventBubble'
|
||||||
import { MessageBubble } from './MessageBubble'
|
import { MessageBubble } from './MessageBubble'
|
||||||
import { MessageGroupModel } from './messageGrouping'
|
import { MessageGroupModel } from './messageGrouping'
|
||||||
|
|
||||||
@@ -26,11 +28,17 @@ export function MessageGroup({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
|
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
|
||||||
{group.messages.map((message) => (
|
{group.messages.map((message) =>
|
||||||
<div key={message.id} className="wechat-system-message-row">
|
// 退群推断事件与真实微信系统消息都落在 isSystem 分支,
|
||||||
<div className="wechat-system-message">{message.content}</div>
|
// 但必须分开渲染:前者是本地推断,后者是微信原话。
|
||||||
</div>
|
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,
|
PersonalWechatSenderStatus,
|
||||||
PersonalWechatVoiceDiagnostic
|
PersonalWechatVoiceDiagnostic
|
||||||
} from '../../../../shared/personal-wechat'
|
} from '../../../../shared/personal-wechat'
|
||||||
import type {
|
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
|
||||||
} from '../../../../shared/personal-wechat-runtime'
|
|
||||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||||
import { isMac, isWindows } from '../../utils/runtime-environment'
|
import { isMac, isWindows } from '../../utils/runtime-environment'
|
||||||
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
||||||
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
||||||
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
||||||
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
|
import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog'
|
||||||
|
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
|
||||||
|
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
|
||||||
|
|
||||||
type SelectedLocalFile = { path: string; name: string }
|
type SelectedLocalFile = { path: string; name: string }
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
|||||||
sipDisabled: false,
|
sipDisabled: false,
|
||||||
wechatRunning: false,
|
wechatRunning: false,
|
||||||
runtimeReady: false,
|
runtimeReady: false,
|
||||||
endpoint: '127.0.0.1:58080',
|
endpoint: '',
|
||||||
endpointReady: false,
|
endpointReady: false,
|
||||||
attachReady: false,
|
attachReady: false,
|
||||||
baseAddressReady: false,
|
baseAddressReady: false,
|
||||||
@@ -67,18 +65,14 @@ function PersonalWechatMacSendDialog({
|
|||||||
initialImage = null
|
initialImage = null
|
||||||
}: PersonalWechatSendDialogProps): React.ReactElement {
|
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||||
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
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 [detecting, setDetecting] = useState(true)
|
||||||
const [binding, setBinding] = useState(false)
|
const [binding, setBinding] = useState(false)
|
||||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
|
||||||
const [sendBusy, setSendBusy] = useState(false)
|
const [sendBusy, setSendBusy] = useState(false)
|
||||||
// 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。
|
// 发送入口只信任当前 native runtime 上报的能力状态。
|
||||||
const [sessionBound, setSessionBound] = useState(false)
|
const [sessionBound, setSessionBound] = useState(false)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [sendError, setSendError] = useState<string | null>(null)
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
|
const [sendSuccess, setSendSuccess] = useState<string | null>(null)
|
||||||
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
||||||
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
||||||
const requestIdRef = useRef(0)
|
const requestIdRef = useRef(0)
|
||||||
@@ -86,23 +80,19 @@ function PersonalWechatMacSendDialog({
|
|||||||
const closingRef = useRef(false)
|
const closingRef = useRef(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const targetId = 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 setupReady = Boolean(initialImage ? senderStatus?.canSendImage : senderStatus?.canSendVoice)
|
||||||
|
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
|
||||||
|
Boolean(initialImage)
|
||||||
|
)
|
||||||
|
|
||||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
const requestId = ++requestIdRef.current
|
const requestId = ++requestIdRef.current
|
||||||
setDetecting(true)
|
setDetecting(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
try {
|
try {
|
||||||
const [nextRuntime, nextSender] = await Promise.all([
|
const nextSender = await window.api.getPersonalWechatSenderStatus()
|
||||||
isMac
|
|
||||||
? window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null)
|
|
||||||
: Promise.resolve(null),
|
|
||||||
window.api.getPersonalWechatSenderStatus()
|
|
||||||
])
|
|
||||||
if (requestId !== requestIdRef.current) return
|
if (requestId !== requestIdRef.current) return
|
||||||
setRuntimeStatus(nextRuntime)
|
|
||||||
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
|
|
||||||
setSenderStatus(nextSender)
|
setSenderStatus(nextSender)
|
||||||
setSessionBound(
|
setSessionBound(
|
||||||
nextSender.state === 'online' ||
|
nextSender.state === 'online' ||
|
||||||
@@ -122,13 +112,6 @@ function PersonalWechatMacSendDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshStatus()
|
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])
|
}, [refreshStatus])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,22 +129,6 @@ function PersonalWechatMacSendDialog({
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [refreshStatus, senderStatus])
|
}, [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> => {
|
const handleBind = async (): Promise<void> => {
|
||||||
if (binding) return
|
if (binding) return
|
||||||
setBinding(true)
|
setBinding(true)
|
||||||
@@ -232,18 +199,27 @@ function PersonalWechatMacSendDialog({
|
|||||||
if (!initialImage || !senderStatus?.canSendImage || sendBusy) return
|
if (!initialImage || !senderStatus?.canSendImage || sendBusy) return
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
|
setSendSuccess(null)
|
||||||
try {
|
try {
|
||||||
|
await persistPostfixText()
|
||||||
const response = await window.api.sendPersonalWechatMessage({
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
to: targetId,
|
to: targetId,
|
||||||
isGroup: isGroupChat,
|
isGroup: isGroupChat,
|
||||||
filePath: initialImage.path
|
filePath: initialImage.path,
|
||||||
|
postfixText
|
||||||
} satisfies PersonalWechatSendRequest)
|
} satisfies PersonalWechatSendRequest)
|
||||||
setSenderStatus(response.status)
|
setSenderStatus(response.status)
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
setSendError(response.error || '日报图片发送失败')
|
setSendError(response.error || '日报图片发送失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (response.postfixError) {
|
||||||
|
setSendError(`日报图片已发送,但${response.postfixError}`)
|
||||||
|
setSendSuccess('日报图片发送成功')
|
||||||
|
} else {
|
||||||
|
setSendSuccess(postfixText.trim() ? '日报图片和后置词发送成功' : '日报图片发送成功')
|
||||||
|
}
|
||||||
setMessages((current) => [
|
setMessages((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
@@ -287,7 +263,7 @@ function PersonalWechatMacSendDialog({
|
|||||||
{displayName.slice(0, 1)}
|
{displayName.slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="personal-wechat-chat-heading">
|
<div className="personal-wechat-chat-heading">
|
||||||
<DialogTitle>文字转语音</DialogTitle>
|
<DialogTitle>{initialImage ? '发送日报图片' : '文字转语音'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
|
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -319,14 +295,10 @@ function PersonalWechatMacSendDialog({
|
|||||||
|
|
||||||
{!setupReady && senderStatus && (
|
{!setupReady && senderStatus && (
|
||||||
<PersonalWechatSetupGuide
|
<PersonalWechatSetupGuide
|
||||||
runtimeStatus={runtimeStatus}
|
|
||||||
senderStatus={senderStatus}
|
senderStatus={senderStatus}
|
||||||
runtimeProgress={runtimeProgress}
|
|
||||||
runtimeBusy={runtimeBusy}
|
|
||||||
binding={binding}
|
binding={binding}
|
||||||
detecting={detecting}
|
detecting={detecting}
|
||||||
sessionBound={sessionBound}
|
sessionBound={sessionBound}
|
||||||
onDownloadRuntime={() => void handleDownloadRuntime()}
|
|
||||||
onBind={() => void handleBind()}
|
onBind={() => void handleBind()}
|
||||||
onStartSending={() => undefined}
|
onStartSending={() => undefined}
|
||||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||||
@@ -336,6 +308,16 @@ function PersonalWechatMacSendDialog({
|
|||||||
{setupReady && initialImage && (
|
{setupReady && initialImage && (
|
||||||
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
||||||
<p>已准备日报图片:{initialImage.name}</p>
|
<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}>
|
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
||||||
{sendBusy ? '发送中…' : '发送日报图片'}
|
{sendBusy ? '发送中…' : '发送日报图片'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -358,8 +340,13 @@ function PersonalWechatMacSendDialog({
|
|||||||
{sendError}
|
{sendError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sendSuccess && (
|
||||||
|
<div className="personal-wechat-global-success" role="status">
|
||||||
|
{sendSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{setupReady && isMac && (
|
{setupReady && isMac && !initialImage && (
|
||||||
<div className="personal-wechat-chat-footer flex items-center justify-end">
|
<div className="personal-wechat-chat-footer flex items-center justify-end">
|
||||||
<Button variant="link" size="sm" onClick={() => void handleOpenVoiceDiagnostic()}>
|
<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 { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||||
import type {
|
import { Button } from '../ui'
|
||||||
PersonalWechatRuntimeProgressEvent,
|
|
||||||
PersonalWechatRuntimeStatus
|
const GROUP_README_URL = 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
|
||||||
} from '../../../../shared/personal-wechat-runtime'
|
|
||||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
|
||||||
import { isMac } from '../../utils/runtime-environment'
|
|
||||||
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
|
|
||||||
|
|
||||||
interface PersonalWechatSetupGuideProps {
|
interface PersonalWechatSetupGuideProps {
|
||||||
runtimeStatus: PersonalWechatRuntimeStatus | null
|
|
||||||
senderStatus: PersonalWechatSenderStatus | null
|
senderStatus: PersonalWechatSenderStatus | null
|
||||||
runtimeProgress: PersonalWechatRuntimeProgressEvent | null
|
|
||||||
runtimeBusy: boolean
|
|
||||||
binding: boolean
|
binding: boolean
|
||||||
detecting: boolean
|
detecting: boolean
|
||||||
sessionBound: boolean
|
sessionBound: boolean
|
||||||
onDownloadRuntime: () => void
|
|
||||||
onBind: () => void
|
onBind: () => void
|
||||||
onStartSending: () => void
|
onStartSending: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
@@ -27,21 +18,18 @@ function capabilityLabel(ready: boolean, initializing: boolean): string {
|
|||||||
return initializing ? '初始化中' : '未就绪'
|
return initializing ? '初始化中' : '未就绪'
|
||||||
}
|
}
|
||||||
|
|
||||||
function diagnosticValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null || value === '') return '未检测到'
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindingHint(status: PersonalWechatSenderStatus): string {
|
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_platform') return '当前系统暂不支持个人微信发送。'
|
||||||
if (status.state === 'unsupported_version')
|
if (status.state === 'unsupported_version')
|
||||||
return '当前微信版本暂不支持,请查看微信发送设置中的支持版本。'
|
return '当前微信版本暂不支持,请联系群主确认可用版本。'
|
||||||
if (status.state === 'runtime_missing') {
|
if (status.state === 'runtime_missing') {
|
||||||
return isMac ? '请先完成 OneBot 运行时准备。' : '请先完成发送运行时准备。'
|
return '请先完成发送运行时准备。'
|
||||||
}
|
}
|
||||||
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
||||||
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
|
return '请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
||||||
@@ -54,28 +42,21 @@ function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PersonalWechatSetupGuide({
|
export function PersonalWechatSetupGuide({
|
||||||
runtimeStatus,
|
|
||||||
senderStatus,
|
senderStatus,
|
||||||
runtimeProgress,
|
|
||||||
runtimeBusy,
|
|
||||||
binding,
|
binding,
|
||||||
detecting,
|
detecting,
|
||||||
sessionBound,
|
sessionBound,
|
||||||
onDownloadRuntime,
|
|
||||||
onBind,
|
onBind,
|
||||||
onStartSending,
|
onStartSending,
|
||||||
onOpenTextToSpeechSettings
|
onOpenTextToSpeechSettings
|
||||||
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
||||||
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
const runtimeUnavailable = senderStatus?.state === 'runtime_missing'
|
||||||
const runtimeLabel = isMac ? 'OneBot 运行时' : '发送运行时'
|
const runtimeLabel = '发送运行时'
|
||||||
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
const runtimeReady = senderStatus?.runtimeReady === true
|
||||||
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
|
||||||
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
||||||
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
||||||
const initializing = connected && !canSendVoice && senderStatus?.state !== 'error'
|
const initializing = connected && !canSendVoice && senderStatus?.state !== 'error'
|
||||||
const allReady = connected && Boolean(senderStatus?.canSend)
|
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 (
|
return (
|
||||||
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
|
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
|
||||||
@@ -89,38 +70,21 @@ export function PersonalWechatSetupGuide({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol className="personal-wechat-steps">
|
<ol className="personal-wechat-steps">
|
||||||
<li
|
<li className={runtimeReady ? 'is-complete' : 'is-current'}>
|
||||||
className={runtimeReady ? 'is-complete' : runtimeDownloading ? 'is-active' : 'is-current'}
|
|
||||||
>
|
|
||||||
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>准备 {runtimeLabel}</strong>
|
<strong>准备 {runtimeLabel}</strong>
|
||||||
<p>个人微信发送需要 {runtimeLabel},首次使用时下载一次即可。</p>
|
<p>
|
||||||
{runtimeDownloading ? (
|
{runtimeUnavailable
|
||||||
<div className="personal-wechat-download-progress" aria-live="polite">
|
? `个人微信发送需要${runtimeLabel},授权后即可使用。`
|
||||||
<div>
|
: `${runtimeLabel}随 TraceMemo 一起提供,无需额外安装。`}
|
||||||
<span>正在准备 {runtimeLabel}</span>
|
</p>
|
||||||
<span>{progressPercent}%</span>
|
{runtimeReady ? (
|
||||||
</div>
|
<span className="personal-wechat-step-status">✓ {runtimeLabel}已就绪</span>
|
||||||
<div className="personal-wechat-progress-track">
|
) : runtimeUnavailable ? (
|
||||||
<span style={{ width: `${progressPercent}%` }} />
|
<span className="personal-wechat-step-error">当前版本暂未提供微信消息发送功能</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : runtimeReady ? (
|
|
||||||
<span className="personal-wechat-step-status">✓ {runtimeLabel}已准备</span>
|
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<span className="personal-wechat-step-status">正在检查 {runtimeLabel}…</span>
|
||||||
size="sm"
|
|
||||||
onClick={onDownloadRuntime}
|
|
||||||
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
|
|
||||||
>
|
|
||||||
下载运行时
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
|
|
||||||
<p className="personal-wechat-step-error">
|
|
||||||
运行时准备失败,请重试或查看微信发送设置。
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -139,7 +103,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
|
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
|
||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>绑定个人微信</strong>
|
<strong>绑定个人微信</strong>
|
||||||
<p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
|
<p>请保持微信未登录窗口状态,点击“绑定微信”后,再点击微信窗口登录。</p>
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
|
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -147,15 +111,28 @@ export function PersonalWechatSetupGuide({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBind}
|
onClick={onBind}
|
||||||
disabled={!runtimeReady || binding || runtimeDownloading}
|
disabled={runtimeUnavailable || !runtimeReady || binding}
|
||||||
>
|
>
|
||||||
{binding ? '正在绑定…' : '绑定微信'}
|
{binding ? '正在绑定…' : '绑定微信'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!connected && senderStatus && (
|
{!connected && senderStatus && !runtimeUnavailable && (
|
||||||
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
|
<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">
|
<p className="personal-wechat-step-warning" role="note">
|
||||||
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
||||||
<br />
|
<br />
|
||||||
@@ -163,14 +140,6 @@ export function PersonalWechatSetupGuide({
|
|||||||
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
className="w-fit"
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowSupportedVersions(true)}
|
|
||||||
>
|
|
||||||
查看支持的微信版本
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -225,7 +194,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
|
{!runtimeReady && runtimeUnavailable && onOpenTextToSpeechSettings && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -235,54 +204,6 @@ export function PersonalWechatSetupGuide({
|
|||||||
查看语音设置
|
查看语音设置
|
||||||
</Button>
|
</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>
|
</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 { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||||
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer'
|
||||||
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
|
import type { PersonalWechatSendDialogProps } from './PersonalWechatSendDialog'
|
||||||
|
import { ReportImagePostfixInput } from './ReportImagePostfixInput'
|
||||||
|
import { useReportImagePostfixSetting } from './useReportImagePostfixSetting'
|
||||||
|
|
||||||
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
||||||
return {
|
return {
|
||||||
@@ -45,7 +47,7 @@ function statusLabel(status: PersonalWechatSenderStatus | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusText(value: string): 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 {
|
function statusDescription(status: PersonalWechatSenderStatus | null): string {
|
||||||
@@ -64,12 +66,17 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||||
const [detecting, setDetecting] = useState(true)
|
const [detecting, setDetecting] = useState(true)
|
||||||
const [sendBusy, setSendBusy] = useState(false)
|
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 [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const requestIdRef = useRef(0)
|
const requestIdRef = useRef(0)
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||||
const closingRef = useRef(false)
|
const closingRef = useRef(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const targetId = contact.m_nsUsrName
|
const targetId = contact.m_nsUsrName
|
||||||
|
const { postfixText, setPostfixText, persistPostfixText } = useReportImagePostfixSetting(
|
||||||
|
Boolean(initialImage)
|
||||||
|
)
|
||||||
|
|
||||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
const requestId = ++requestIdRef.current
|
const requestId = ++requestIdRef.current
|
||||||
@@ -103,9 +110,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
onOpenPersonalWechatSettings()
|
onOpenPersonalWechatSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async (
|
const handleSend = async (filePath: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
filePath: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
try {
|
try {
|
||||||
const response = await window.api.sendGeneratedTtsVoice({
|
const response = await window.api.sendGeneratedTtsVoice({
|
||||||
@@ -137,15 +142,28 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
const handleSendReportImage = async (): Promise<void> => {
|
const handleSendReportImage = async (): Promise<void> => {
|
||||||
if (!initialImage || !status?.canSendImage || sendBusy) return
|
if (!initialImage || !status?.canSendImage || sendBusy) return
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
|
setSendError(null)
|
||||||
|
setSendSuccess(null)
|
||||||
try {
|
try {
|
||||||
|
await persistPostfixText()
|
||||||
const response = await window.api.sendPersonalWechatMessage({
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
to: targetId,
|
to: targetId,
|
||||||
isGroup: isGroupChat,
|
isGroup: isGroupChat,
|
||||||
filePath: initialImage.path
|
filePath: initialImage.path,
|
||||||
|
postfixText
|
||||||
} satisfies PersonalWechatSendRequest)
|
} satisfies PersonalWechatSendRequest)
|
||||||
setStatus(response.status)
|
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) => [
|
setMessages((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
@@ -156,6 +174,8 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
outgoing: true
|
outgoing: true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
} catch (error) {
|
||||||
|
setSendError(error instanceof Error ? error.message : String(error))
|
||||||
} finally {
|
} finally {
|
||||||
setSendBusy(false)
|
setSendBusy(false)
|
||||||
}
|
}
|
||||||
@@ -183,7 +203,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
|
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
|
<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>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +221,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="personal-wechat-send-target">
|
<div className="personal-wechat-send-target">
|
||||||
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
||||||
<strong>{displayName}</strong>
|
<strong>{displayName}</strong>
|
||||||
<code>{targetId}</code>
|
<code>{targetId}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +266,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
key={message.id}
|
key={message.id}
|
||||||
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
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>
|
<span>{message.text || message.fileName}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -255,6 +275,16 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
{initialImage && status.canSendImage ? (
|
{initialImage && status.canSendImage ? (
|
||||||
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
||||||
<p>已准备日报图片:{initialImage.name}</p>
|
<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}>
|
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
||||||
{sendBusy ? '发送中…' : '发送日报图片'}
|
{sendBusy ? '发送中…' : '发送日报图片'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -271,6 +301,16 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
busy={sendBusy}
|
busy={sendBusy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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}
|
) : null}
|
||||||
</DialogContent>
|
</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 (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
<circle cx="5" cy="12" r="1.4" />
|
<path d="M5.5 19v-6" />
|
||||||
<circle cx="12" cy="12" r="1.4" />
|
<path d="M12 19V5" />
|
||||||
<circle cx="19" cy="12" r="1.4" />
|
<path d="M18.5 19v-9" />
|
||||||
</svg>
|
</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 }
|
||||||
|
}
|
||||||
@@ -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" />
|
<path d="M5.5 15.5v3h13v-3" />
|
||||||
</svg>
|
</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':
|
case 'agent-hub':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type AppPage =
|
|||||||
| 'search'
|
| 'search'
|
||||||
| 'report'
|
| 'report'
|
||||||
| 'exit-monitor'
|
| 'exit-monitor'
|
||||||
|
| 'automation'
|
||||||
| 'agent-hub'
|
| 'agent-hub'
|
||||||
| 'export'
|
| 'export'
|
||||||
| 'api'
|
| 'api'
|
||||||
@@ -18,6 +19,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
|||||||
{ id: 'search', label: '问问微信' },
|
{ id: 'search', label: '问问微信' },
|
||||||
{ id: 'report', label: '日报' },
|
{ id: 'report', label: '日报' },
|
||||||
{ id: 'exit-monitor', label: '退群监控' },
|
{ id: 'exit-monitor', label: '退群监控' },
|
||||||
|
{ id: 'automation', label: '自动化' },
|
||||||
{ id: 'agent-hub', label: 'Agent' },
|
{ id: 'agent-hub', label: 'Agent' },
|
||||||
{ id: 'export', label: '导出' },
|
{ id: 'export', label: '导出' },
|
||||||
{ id: 'api', label: 'API' },
|
{ id: 'api', label: 'API' },
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Checkbox } from '../ui'
|
|||||||
|
|
||||||
interface MessageTypeSelectorProps {
|
interface MessageTypeSelectorProps {
|
||||||
value: SummaryMessageType[]
|
value: SummaryMessageType[]
|
||||||
counts: Record<SummaryMessageType, number>
|
/**
|
||||||
|
* 每类消息的条数。
|
||||||
|
*
|
||||||
|
* 可选:`自动化 → 定时日报` 的编辑器没有真实消息统计,
|
||||||
|
* 不传就不显示数字,而不是显示一排假的 0。
|
||||||
|
*/
|
||||||
|
counts?: Record<SummaryMessageType, number>
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
onChange: (value: SummaryMessageType[]) => void
|
onChange: (value: SummaryMessageType[]) => void
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,7 @@ export function MessageTypeSelector({
|
|||||||
<b>{option.label}</b>
|
<b>{option.label}</b>
|
||||||
<small>{option.description}</small>
|
<small>{option.description}</small>
|
||||||
</span>
|
</span>
|
||||||
<em>{counts[option.value]}</em>
|
{counts ? <em>{counts[option.value]}</em> : null}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<i />
|
<i />
|
||||||
本机 HTTP 通信
|
在主进程内运行,不开放本地端口
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<i />
|
<i />
|
||||||
入站请求鉴权
|
对话记录可回看
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<i />
|
<i />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Input, RadioGroup, RadioGroupItem } from '../../components/ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeaveNotificationTargetPicker —— 「指定好友」展开后的联系人选择器。
|
||||||
|
*
|
||||||
|
* **单选**:通知只能发给一个联系人,所以这里是 radio 而不是 checkbox ——
|
||||||
|
* 多选联系人不在范围内,也不该顺手加。
|
||||||
|
*
|
||||||
|
* 联系人清单由 main 侧提供(`listSendableContacts`),**已经过滤掉**
|
||||||
|
* 群聊 / 公众号 / 文件传输助手 / 自己。这里不再二次筛选,避免两处规则漂移。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaveNotificationContactOption {
|
||||||
|
/** 稳定 id(wxid)。保存与发送都用它,**不用显示名**。 */
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveNotificationTargetPickerProps {
|
||||||
|
contacts: LeaveNotificationContactOption[]
|
||||||
|
selectedId: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
searchPlaceholder: string
|
||||||
|
emptyText: string
|
||||||
|
ariaLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaveNotificationTargetPicker({
|
||||||
|
contacts,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyText,
|
||||||
|
ariaLabel
|
||||||
|
}: LeaveNotificationTargetPickerProps): React.ReactElement {
|
||||||
|
const [filter, setFilter] = React.useState('')
|
||||||
|
|
||||||
|
const visibleContacts = React.useMemo(() => {
|
||||||
|
const needle = filter.trim().toLowerCase()
|
||||||
|
if (!needle) return contacts
|
||||||
|
return contacts.filter(
|
||||||
|
(contact) =>
|
||||||
|
contact.name.toLowerCase().includes(needle) || contact.id.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
}, [contacts, filter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedId}
|
||||||
|
onValueChange={onSelect}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="automation-leave-contact-picker"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
aria-label={searchPlaceholder}
|
||||||
|
/>
|
||||||
|
<div className="automation-leave-contact-list">
|
||||||
|
{visibleContacts.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">{emptyText}</p>
|
||||||
|
) : (
|
||||||
|
visibleContacts.map((contact) => (
|
||||||
|
<div key={contact.id} className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem value={contact.id} id={`leave-contact-${contact.id}`} />
|
||||||
|
<label htmlFor={`leave-contact-${contact.id}`}>{contact.name}</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Textarea } from '../../components/ui'
|
||||||
|
import {
|
||||||
|
GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS,
|
||||||
|
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||||
|
GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS
|
||||||
|
} from '../../../../shared/group-exit-monitor'
|
||||||
|
|
||||||
|
/** 在光标处插入一个变量;没有光标位置就追加到末尾。纯 UI 辅助。 */
|
||||||
|
function insertTemplateVariable(
|
||||||
|
template: string,
|
||||||
|
variable: string,
|
||||||
|
selectionStart?: number | null,
|
||||||
|
selectionEnd?: number | null
|
||||||
|
): string {
|
||||||
|
const token = `{${variable}}`
|
||||||
|
const start = typeof selectionStart === 'number' ? selectionStart : template.length
|
||||||
|
const end = typeof selectionEnd === 'number' ? selectionEnd : start
|
||||||
|
return `${template.slice(0, start)}${token}${template.slice(end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LeaveNotificationTemplateEditor —— 「3 · 通知内容」。
|
||||||
|
*
|
||||||
|
* 占位符清单直接复用 shared 常量 —— UI 能插的变量与真实解释逻辑必须是同一份,
|
||||||
|
* 否则会出现"插得进去、发出去不变"的变量。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaveNotificationTemplateEditorProps {
|
||||||
|
template: string
|
||||||
|
onChange: (next: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaveNotificationTemplateEditor({
|
||||||
|
template,
|
||||||
|
onChange
|
||||||
|
}: LeaveNotificationTemplateEditorProps): React.ReactElement {
|
||||||
|
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const insertVariable = (variable: string): void => {
|
||||||
|
const element = textareaRef.current
|
||||||
|
onChange(
|
||||||
|
insertTemplateVariable(
|
||||||
|
template,
|
||||||
|
variable,
|
||||||
|
element?.selectionStart ?? null,
|
||||||
|
element?.selectionEnd ?? null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-leave-template">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
id="automation-leave-template"
|
||||||
|
aria-label="退群通知模板内容"
|
||||||
|
value={template}
|
||||||
|
rows={11}
|
||||||
|
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="automation-leave-template-editor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="automation-leave-template-meta">
|
||||||
|
<span>支持</span>
|
||||||
|
<span className="automation-leave-template-chips">
|
||||||
|
{GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS.map((placeholder) => (
|
||||||
|
<button
|
||||||
|
key={placeholder}
|
||||||
|
type="button"
|
||||||
|
className="automation-leave-template-chip"
|
||||||
|
// 悬停就能看到含义 —— 光看 {groupRemark} 这种符号谁也猜不出它是什么。
|
||||||
|
title={`{${placeholder}}:${GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS[placeholder]}`}
|
||||||
|
onClick={() => insertVariable(placeholder)}
|
||||||
|
aria-label={`插入变量 ${placeholder}`}
|
||||||
|
>
|
||||||
|
{`{${placeholder}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="automation-leave-template-count">
|
||||||
|
{template.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
先把最容易混的两项说清楚,再说兜底。
|
||||||
|
实测有人把 {groupRemark} 当成群名(它其实是成员在本群的昵称),
|
||||||
|
于是以为通知里已经有群名了 —— 所以这句必须放在最前面、直说区别。
|
||||||
|
*/}
|
||||||
|
<p className="automation-leave-template-note">
|
||||||
|
{`{groupName}`} 是<strong>群聊名</strong>;{`{groupRemark}`} 是退群成员
|
||||||
|
<strong>在本群的昵称</strong>(不是群名)。取不到值时:{`{groupName}`}、{`{user}`}、
|
||||||
|
{`{wxid}`} 显示「未读取到」,{`{groupRemark}`} 显示「未设置」。
|
||||||
|
悬停变量可看含义;通知只发送到上面选定的目标。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
AUTOMATION_REPLY_DELAY_MAX_SECONDS,
|
||||||
|
DEFAULT_REPLY_TEXT,
|
||||||
|
KEYWORD_MATCH_MODE_LABELS,
|
||||||
|
createDefaultDailyReportRule,
|
||||||
|
normalizeReplyDelaySeconds,
|
||||||
|
normalizeRuleDraft,
|
||||||
|
type AutomationActionType,
|
||||||
|
type AutomationRule,
|
||||||
|
type AutomationRuleDraft,
|
||||||
|
type KeywordMatchMode
|
||||||
|
} from '../../../../shared/automation'
|
||||||
|
import { Button, Input, SegmentedControl, SegmentedControlItem, Switch } from '../../components/ui'
|
||||||
|
import { EffectPreview } from './EffectPreview'
|
||||||
|
import type { AutomationGroupOption } from './model/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RuleEditorPanel —— 规则编辑页(对应 Stitch 设计稿 1)。
|
||||||
|
*
|
||||||
|
* 结构固定五段:什么时候触发 → 在哪些聊天生效 → 触发后执行 → 高级设置 → 效果预览。
|
||||||
|
*
|
||||||
|
* 刻意**不出现**工程词:没有 dedup / idempotency / watermark / regex,
|
||||||
|
* 用户看到的只有「同一消息只执行一次」这类人话。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEYWORD_MODES: KeywordMatchMode[] = ['contains', 'exact', 'prefix']
|
||||||
|
|
||||||
|
const ACTION_META: Array<{
|
||||||
|
type: AutomationActionType
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
type: 'replyText',
|
||||||
|
title: '回复确认',
|
||||||
|
description: '先在群里回一句,告诉对方已经收到'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'generateReport',
|
||||||
|
title: '生成日报',
|
||||||
|
description: '用当前群的今日消息生成日报'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sendReportImage',
|
||||||
|
title: '发送日报图片',
|
||||||
|
description: '把生成的日报图片发回触发消息所在的群'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function draftFromRule(rule: AutomationRule | null): AutomationRuleDraft {
|
||||||
|
const base = rule ?? createDefaultDailyReportRule(Date.now())
|
||||||
|
return normalizeRuleDraft({
|
||||||
|
name: base.name,
|
||||||
|
enabled: base.enabled,
|
||||||
|
scope: base.scope,
|
||||||
|
conditions: base.conditions,
|
||||||
|
actions: base.actions,
|
||||||
|
cooldownSeconds: base.cooldownSeconds,
|
||||||
|
// 漏掉一个字段就会被 normalizeRuleDraft 的默认值悄悄覆盖 ——
|
||||||
|
// 比如把用户设成 0 的「回复前等待」重置回 2 秒。
|
||||||
|
replyDelaySeconds: base.replyDelaySeconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleEditorPanelProps {
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
rule: AutomationRule | null
|
||||||
|
groups: AutomationGroupOption[]
|
||||||
|
saving: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: (draft: AutomationRuleDraft) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleEditorPanel({
|
||||||
|
mode,
|
||||||
|
rule,
|
||||||
|
groups,
|
||||||
|
saving,
|
||||||
|
onCancel,
|
||||||
|
onSave
|
||||||
|
}: RuleEditorPanelProps): React.ReactElement {
|
||||||
|
const [draft, setDraft] = React.useState<AutomationRuleDraft>(() => draftFromRule(rule))
|
||||||
|
const [groupFilter, setGroupFilter] = React.useState('')
|
||||||
|
const [validationError, setValidationError] = React.useState('')
|
||||||
|
|
||||||
|
// 切换编辑对象时重建草稿,避免把上一条规则的修改带过去。
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDraft(draftFromRule(rule))
|
||||||
|
setValidationError('')
|
||||||
|
}, [rule])
|
||||||
|
|
||||||
|
const patchConditions = (patch: Partial<AutomationRuleDraft['conditions']>): void => {
|
||||||
|
setDraft((current) => ({ ...current, conditions: { ...current.conditions, ...patch } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchAction = (type: AutomationActionType, patch: { enabled?: boolean; text?: string }): void => {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
actions: current.actions.map((action) =>
|
||||||
|
action.type === type ? { ...action, ...patch } : action
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionOf = (type: AutomationActionType): { enabled: boolean; text?: string } =>
|
||||||
|
draft.actions.find((action) => action.type === type) ?? { enabled: false }
|
||||||
|
|
||||||
|
const toggleGroup = (id: string): void => {
|
||||||
|
setDraft((current) => {
|
||||||
|
const selected = new Set(current.conditions.conversationIds)
|
||||||
|
if (selected.has(id)) selected.delete(id)
|
||||||
|
else selected.add(id)
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
conditions: { ...current.conditions, conversationIds: Array.from(selected) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleGroups = React.useMemo(() => {
|
||||||
|
const needle = groupFilter.trim().toLowerCase()
|
||||||
|
if (!needle) return groups
|
||||||
|
return groups.filter(
|
||||||
|
(group) =>
|
||||||
|
group.name.toLowerCase().includes(needle) || group.id.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
}, [groups, groupFilter])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效范围的实际状态。
|
||||||
|
*
|
||||||
|
* 现在的交互是「勾选群 = 指定群聊;一个都不勾 = 所有群聊」,所以没有单独的
|
||||||
|
* 「所有群聊 / 指定群聊」单选控件 —— 不去动第 2 节的结构,只把这个状态显式说出来,
|
||||||
|
* 好让「所有群聊」那条例外提示挂在对的上下文里。
|
||||||
|
*/
|
||||||
|
const hasSelectedGroups = draft.conditions.conversationIds.length > 0
|
||||||
|
|
||||||
|
const handleSave = (): void => {
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
setValidationError('请填写自动化名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!draft.actions.some((action) => action.enabled)) {
|
||||||
|
setValidationError('至少要启用一个执行动作,否则规则命中后什么也不会发生')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setValidationError('')
|
||||||
|
onSave(draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-editor">
|
||||||
|
<header className="automation-editor-header">
|
||||||
|
<div>
|
||||||
|
<h2>{mode === 'create' ? '新建自动化' : '编辑自动化'}</h2>
|
||||||
|
<p>命中条件后,TraceMemo 会按下面的顺序依次执行。</p>
|
||||||
|
</div>
|
||||||
|
<div className="automation-editor-actions">
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{validationError ? <p className="automation-form-error">{validationError}</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-field">
|
||||||
|
<label htmlFor="automation-name">名称</label>
|
||||||
|
<Input
|
||||||
|
id="automation-name"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({ ...current, name: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="给这条自动化起个名字"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<span className="automation-field-label">触发方式</span>
|
||||||
|
<span className="automation-static-value">收到消息</span>
|
||||||
|
</div>
|
||||||
|
<div className="automation-switch-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">必须真正 @我</span>
|
||||||
|
<small>根据微信底层 @ 元数据判断,不依赖普通昵称纯文本</small>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.conditions.requireMentionMe}
|
||||||
|
onCheckedChange={(checked) => patchConditions({ requireMentionMe: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-switch-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">启用这条自动化</span>
|
||||||
|
<small>关闭后规则会保留,但不会触发</small>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft((current) => ({ ...current, enabled: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-field">
|
||||||
|
<label htmlFor="automation-keyword">关键词</label>
|
||||||
|
<Input
|
||||||
|
id="automation-keyword"
|
||||||
|
value={draft.conditions.keyword}
|
||||||
|
onChange={(event) => patchConditions({ keyword: event.target.value })}
|
||||||
|
placeholder="例如:日报(留空表示不限关键词)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">匹配方式</span>
|
||||||
|
<SegmentedControl
|
||||||
|
value={draft.conditions.keywordMatchMode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patchConditions({ keywordMatchMode: value as KeywordMatchMode })
|
||||||
|
}
|
||||||
|
aria-label="关键词匹配方式"
|
||||||
|
>
|
||||||
|
{KEYWORD_MODES.map((mode) => (
|
||||||
|
<SegmentedControlItem key={mode} value={mode}>
|
||||||
|
{KEYWORD_MATCH_MODE_LABELS[mode]}
|
||||||
|
</SegmentedControlItem>
|
||||||
|
))}
|
||||||
|
</SegmentedControl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>2 · 在哪些聊天生效</h3>
|
||||||
|
<span className="automation-section-note">
|
||||||
|
{hasSelectedGroups
|
||||||
|
? `已选 ${draft.conditions.conversationIds.length} 个群`
|
||||||
|
: '所有群聊'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(event) => setGroupFilter(event.target.value)}
|
||||||
|
placeholder="搜索群聊"
|
||||||
|
aria-label="搜索群聊"
|
||||||
|
/>
|
||||||
|
<div className="automation-group-list" role="group" aria-label="生效群聊">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">
|
||||||
|
还没有读取到群聊。请先在「档案」中连接并解锁微信数据库。
|
||||||
|
</p>
|
||||||
|
) : visibleGroups.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">没有匹配「{groupFilter}」的群聊</p>
|
||||||
|
) : (
|
||||||
|
visibleGroups.map((group) => {
|
||||||
|
const checked = draft.conditions.conversationIds.includes(group.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={group.id}
|
||||||
|
className={`automation-group-option ${checked ? 'selected' : ''}`}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={checked} onChange={() => toggleGroup(group.id)} />
|
||||||
|
<span>{group.name}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasSelectedGroups ? null : (
|
||||||
|
// 只在「所有群聊」这个上下文里说明真实边界。
|
||||||
|
// 不解释底层原因(表事件 / 会话回读),也不写「100% 覆盖」这种绝对承诺。
|
||||||
|
<p className="automation-section-hint">
|
||||||
|
<span aria-hidden="true">ⓘ</span>
|
||||||
|
当前版本在多个会话同时收到消息时,极少数自动化触发可能遗漏。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>3 · 触发后执行</h3>
|
||||||
|
<span className="automation-section-note">按下列顺序执行</span>
|
||||||
|
</div>
|
||||||
|
{/* 第一步不是动作,而是「等多久」—— 所以放在动作列表之前,不混进 ACTION_META。 */}
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">回复前等待(秒)</span>
|
||||||
|
<small>命中后先等这么久再回复,避免秒回显得像机器人;0 = 立刻回复</small>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={AUTOMATION_REPLY_DELAY_MAX_SECONDS}
|
||||||
|
value={String(draft.replyDelaySeconds)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value)
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
replyDelaySeconds: normalizeReplyDelaySeconds(
|
||||||
|
Number.isFinite(next) ? next : 0
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
className="automation-number-input"
|
||||||
|
aria-label="回复前等待秒数"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ACTION_META.map((meta) => {
|
||||||
|
const action = actionOf(meta.type)
|
||||||
|
return (
|
||||||
|
<div key={meta.type} className="automation-action-row">
|
||||||
|
<div className="automation-action-main">
|
||||||
|
<div className="automation-switch-row compact">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">{meta.title}</span>
|
||||||
|
<small>{meta.description}</small>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={action.enabled}
|
||||||
|
onCheckedChange={(checked) => patchAction(meta.type, { enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{meta.type === 'replyText' && action.enabled ? (
|
||||||
|
<Input
|
||||||
|
value={action.text ?? ''}
|
||||||
|
onChange={(event) => patchAction(meta.type, { text: event.target.value })}
|
||||||
|
placeholder={DEFAULT_REPLY_TEXT}
|
||||||
|
aria-label="回复内容"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>4 · 高级设置</h3>
|
||||||
|
</div>
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">触发间隔(秒)</span>
|
||||||
|
{/* 这句就是本产品的门语义定义,不要写 debounce / mutex / in-flight 这类技术词。 */}
|
||||||
|
<small>
|
||||||
|
触发后,在当前任务执行期间及设定间隔内,不再处理本规则在该会话中的新消息。
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={String(draft.cooldownSeconds)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value)
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
cooldownSeconds: Number.isFinite(next) ? Math.max(0, Math.floor(next)) : 0
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
className="automation-number-input"
|
||||||
|
aria-label="触发间隔秒数"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="automation-switch-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">忽略自己发送的消息</span>
|
||||||
|
<small>
|
||||||
|
{draft.conditions.ignoreSelf
|
||||||
|
? '推荐保持开启,可避免自动回复被再次触发'
|
||||||
|
: '关闭后可能形成自动回复循环,请确认你知道后果'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.conditions.ignoreSelf}
|
||||||
|
onCheckedChange={(checked) => patchConditions({ ignoreSelf: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="automation-static-note">同一消息只执行一次。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EffectPreview draft={draft} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
AUTOMATION_RULE_TEMPLATES,
|
||||||
|
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
|
describeLeaveNotificationNotifyScope,
|
||||||
|
describeLeaveNotificationTarget,
|
||||||
|
describeRuleActions,
|
||||||
|
describeRuleTrigger,
|
||||||
|
type AutomationRule
|
||||||
|
} from '../../../../shared/automation'
|
||||||
|
import { Button, Spinner, Switch } from '../../components/ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RuleListPanel —— 规则列表(「规则」tab)。
|
||||||
|
*
|
||||||
|
* 两个区块:
|
||||||
|
* 1. **已配置的自动化** —— 全部是**真规则**,带启停开关;
|
||||||
|
* 2. **模板入口** —— 尚未接通的能力必须**如实标注**为「即将支持」。
|
||||||
|
*
|
||||||
|
* 「退群通知」现在是真规则:状态按真实 rule 展示(运行中 / 已关闭 / 配置异常),
|
||||||
|
* 不再有「待配置」「UI 预览」这类占位标识。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LeaveNotificationCardModel {
|
||||||
|
rule: AutomationRule
|
||||||
|
/** 真实已监控群聊数量(来自退群监控)。 */
|
||||||
|
monitoredCount: number
|
||||||
|
/** 「指定好友」目标的显示名;未选 / 不是该目标时为空串。 */
|
||||||
|
contactName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报在首页的**汇总卡**数据。
|
||||||
|
*
|
||||||
|
* 它是多条规则,不能像 singleton 那样当成一条展示(也不能给它一个启停开关)。
|
||||||
|
*/
|
||||||
|
export interface ScheduledReportCardModel {
|
||||||
|
total: number
|
||||||
|
running: number
|
||||||
|
nextRunLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleListPanelProps {
|
||||||
|
rules: AutomationRule[]
|
||||||
|
groups: Array<{ id: string; name: string }>
|
||||||
|
loading: boolean
|
||||||
|
busyRuleId: string | null
|
||||||
|
leaveNotification: LeaveNotificationCardModel | null
|
||||||
|
scheduledReport: ScheduledReportCardModel | null
|
||||||
|
onToggle: (rule: AutomationRule, enabled: boolean) => void
|
||||||
|
onEdit: (rule: AutomationRule) => void
|
||||||
|
onDelete: (rule: AutomationRule) => void
|
||||||
|
onCreate: () => void
|
||||||
|
/** 打开「退群通知」规则类型。 */
|
||||||
|
onEditLeaveNotification: () => void
|
||||||
|
/** 打开「定时日报」规则类型(多条,所以是"管理"而不是"编辑")。 */
|
||||||
|
onManageScheduledReport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */
|
||||||
|
function scopeLabel(rule: AutomationRule, groups: Array<{ id: string; name: string }>): string {
|
||||||
|
const selected = rule.conditions.conversationIds
|
||||||
|
if (!selected.length) return '所有群聊'
|
||||||
|
const names = selected
|
||||||
|
.map((id) => groups.find((group) => group.id === id)?.name)
|
||||||
|
.filter((name): name is string => Boolean(name))
|
||||||
|
if (!names.length) return `已选 ${selected.length} 个群`
|
||||||
|
if (names.length <= 2) return names.join('、')
|
||||||
|
return `${names.slice(0, 2).join('、')} 等 ${names.length} 个群`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群通知的真实状态。
|
||||||
|
*
|
||||||
|
* 「配置异常」只对应一种真实情况:迁移时旧「通知群聊」无法无损映射,
|
||||||
|
* 规则被标成待重选目标 —— 这时它不会发送,需要用户处理。
|
||||||
|
*/
|
||||||
|
function leaveStatus(rule: AutomationRule): { label: string; tone: 'on' | 'off' | 'pending' } {
|
||||||
|
if (rule.leaveNotification?.targetNeedsReview) return { label: '配置异常', tone: 'pending' }
|
||||||
|
return rule.enabled ? { label: '运行中', tone: 'on' } : { label: '已关闭', tone: 'off' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleListPanel({
|
||||||
|
rules,
|
||||||
|
groups,
|
||||||
|
loading,
|
||||||
|
busyRuleId,
|
||||||
|
leaveNotification,
|
||||||
|
scheduledReport,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCreate,
|
||||||
|
onEditLeaveNotification,
|
||||||
|
onManageScheduledReport
|
||||||
|
}: RuleListPanelProps): React.ReactElement {
|
||||||
|
// 退群通知有自己的卡片,别在「已配置的自动化」里重复渲染一遍。
|
||||||
|
const configuredRules = rules.filter((rule) => rule.id !== BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||||
|
const leaveRule = leaveNotification?.rule ?? null
|
||||||
|
const leaveState = leaveRule ? leaveStatus(leaveRule) : null
|
||||||
|
const configuredCount = configuredRules.length + (leaveRule ? 1 : 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-rule-list">
|
||||||
|
<section className="automation-rule-group">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h2>已配置的自动化</h2>
|
||||||
|
<span className="automation-section-note">{configuredCount} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="automation-loading">
|
||||||
|
<Spinner />
|
||||||
|
<span>正在读取规则…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!configuredRules.length && !leaveRule ? (
|
||||||
|
<div className="automation-empty-card">
|
||||||
|
<p>还没有任何自动化。</p>
|
||||||
|
<Button onClick={onCreate}>新建自动化</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{configuredRules.map((rule) => (
|
||||||
|
<article
|
||||||
|
key={rule.id}
|
||||||
|
className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}
|
||||||
|
>
|
||||||
|
<div className="automation-rule-card-main">
|
||||||
|
<div className="automation-rule-card-title">
|
||||||
|
<h3>{rule.name}</h3>
|
||||||
|
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
|
||||||
|
{rule.enabled ? '运行中' : '已停用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="automation-rule-meta">
|
||||||
|
<div>
|
||||||
|
<dt>触发</dt>
|
||||||
|
<dd>{describeRuleTrigger(rule)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>范围</dt>
|
||||||
|
<dd>{scopeLabel(rule, groups)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>动作</dt>
|
||||||
|
<dd>{describeRuleActions(rule)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>间隔</dt>
|
||||||
|
<dd>{rule.cooldownSeconds > 0 ? `${rule.cooldownSeconds} 秒` : '不限制'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="automation-rule-card-side">
|
||||||
|
<Switch
|
||||||
|
checked={rule.enabled}
|
||||||
|
disabled={busyRuleId === rule.id}
|
||||||
|
onCheckedChange={(checked) => onToggle(rule, checked)}
|
||||||
|
aria-label={`${rule.name} 启停`}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onEdit(rule)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(rule)}
|
||||||
|
disabled={busyRuleId === rule.id}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{leaveRule && leaveState ? (
|
||||||
|
<article
|
||||||
|
className={`automation-rule-card ${leaveRule.enabled && leaveState.tone === 'on' ? '' : 'disabled'}`}
|
||||||
|
>
|
||||||
|
<div className="automation-rule-card-main">
|
||||||
|
<div className="automation-rule-card-title">
|
||||||
|
<h3>{leaveRule.name}</h3>
|
||||||
|
<span className={`automation-rule-state ${leaveState.tone}`}>
|
||||||
|
{leaveState.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="automation-rule-meta">
|
||||||
|
<div>
|
||||||
|
<dt>触发</dt>
|
||||||
|
<dd>{describeRuleTrigger(leaveRule)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>通知范围</dt>
|
||||||
|
<dd>
|
||||||
|
{describeLeaveNotificationNotifyScope(
|
||||||
|
leaveRule.leaveNotification,
|
||||||
|
leaveNotification?.monitoredCount ?? 0
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>动作</dt>
|
||||||
|
<dd>{describeRuleActions(leaveRule)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>目标</dt>
|
||||||
|
<dd>
|
||||||
|
{describeLeaveNotificationTarget(
|
||||||
|
leaveRule.leaveNotification,
|
||||||
|
leaveNotification?.contactName
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="automation-rule-card-side">
|
||||||
|
<Switch
|
||||||
|
checked={leaveRule.enabled}
|
||||||
|
disabled={busyRuleId === leaveRule.id}
|
||||||
|
onCheckedChange={(checked) => onToggle(leaveRule, checked)}
|
||||||
|
aria-label={`${leaveRule.name} 启停`}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={onEditLeaveNotification}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
{scheduledReport ? (
|
||||||
|
<article className="automation-rule-card">
|
||||||
|
<div className="automation-rule-card-main">
|
||||||
|
<div className="automation-rule-card-title">
|
||||||
|
<h3>定时日报</h3>
|
||||||
|
{/* 汇总卡:它代表多条规则,所以只报数量,不给单个启停开关。 */}
|
||||||
|
<span
|
||||||
|
className={`automation-rule-state ${scheduledReport.running > 0 ? 'on' : 'off'}`}
|
||||||
|
>
|
||||||
|
{scheduledReport.total} 条规则 · {scheduledReport.running} 条运行中
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl className="automation-rule-meta">
|
||||||
|
<div>
|
||||||
|
<dt>触发</dt>
|
||||||
|
<dd>按设定时间</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>下次执行</dt>
|
||||||
|
<dd>{scheduledReport.nextRunLabel}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="automation-rule-card-side">
|
||||||
|
<Button variant="outline" size="sm" onClick={onManageScheduledReport}>
|
||||||
|
管理定时日报 →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="automation-rule-group">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h2>更多模板</h2>
|
||||||
|
</div>
|
||||||
|
<div className="automation-template-grid">
|
||||||
|
{AUTOMATION_RULE_TEMPLATES.map((template) => (
|
||||||
|
<article key={template.id} className="automation-template-card">
|
||||||
|
<div className="automation-template-title">
|
||||||
|
<h3>{template.name}</h3>
|
||||||
|
<span className="automation-template-flag">即将支持</span>
|
||||||
|
</div>
|
||||||
|
<p>{template.description}</p>
|
||||||
|
{template.unavailableReason ? <small>{template.unavailableReason}</small> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
RadioGroup,
|
||||||
|
RadioGroupItem,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Switch
|
||||||
|
} from '../../components/ui'
|
||||||
|
import { MessageTypeSelector } from '../../components/reports/MessageTypeSelector'
|
||||||
|
import {
|
||||||
|
DEFAULT_REPORT_IMAGE_POSTFIX_TEXT,
|
||||||
|
SCHEDULED_REPORT_MEMBER_NAME_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
|
||||||
|
SCHEDULED_REPORT_RANGE_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS,
|
||||||
|
composeScheduleTime,
|
||||||
|
formatScheduledSlot,
|
||||||
|
parseScheduleTime,
|
||||||
|
resolveGroupDisplayName,
|
||||||
|
type ScheduledReportDraft,
|
||||||
|
type ScheduledReportTargetType
|
||||||
|
} from './model/scheduled-report-model'
|
||||||
|
import { LeaveNotificationTargetPicker } from './LeaveNotificationTargetPicker'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScheduledReportEditor —— 「自动化 → 规则 → 定时日报」的编辑器。
|
||||||
|
*
|
||||||
|
* 三段:什么时候触发 / 生成什么日报 / 生成后发送到哪里。
|
||||||
|
*
|
||||||
|
* Section 2 承载完整的日报配置(纳入的消息类型 / 日报内容 / 模型配置 /
|
||||||
|
* 日报模板 / 成员名称 / 生成超时),字段全部来自真实模型 —— 不发明,也不删减。
|
||||||
|
*
|
||||||
|
* 草稿仍然是 renderer local state,但**保存是真的落盘**(父层调 `automation:*`)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScheduledReportEditorProps {
|
||||||
|
initialDraft: ScheduledReportDraft
|
||||||
|
/** 可选群聊(自动化既有的群列表,与其它规则类型同一来源)。 */
|
||||||
|
groups: Array<{ id: string; name: string }>
|
||||||
|
/** 「指定好友」的候选项(与退群通知共用同一个来源与组件)。 */
|
||||||
|
sendableContacts: Array<{ id: string; name: string }>
|
||||||
|
saving: boolean
|
||||||
|
/** 返回「定时日报」规则列表(**不是**退回自动化首页)。 */
|
||||||
|
onBack: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: (draft: ScheduledReportDraft) => void
|
||||||
|
/** 复用现有的模型配置入口。 */
|
||||||
|
onOpenModelSettings?: () => void
|
||||||
|
/**
|
||||||
|
* 迁移遗留的旧目标原文(只作展示)。
|
||||||
|
*
|
||||||
|
* 有值时说明这条规则是迁移过来的、当时的目标无法自动对应 ——
|
||||||
|
* 界面必须让用户**看见原来的目标是什么**再重选。
|
||||||
|
*/
|
||||||
|
legacyTarget?: string
|
||||||
|
/** 微信异常通知的入口(底部一块,与退群通知同一套开关组件)。 */
|
||||||
|
notificationSlot?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduledReportEditor({
|
||||||
|
initialDraft,
|
||||||
|
groups,
|
||||||
|
sendableContacts,
|
||||||
|
saving,
|
||||||
|
onBack,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
onOpenModelSettings,
|
||||||
|
legacyTarget,
|
||||||
|
notificationSlot
|
||||||
|
}: ScheduledReportEditorProps): React.ReactElement {
|
||||||
|
const [draft, setDraft] = React.useState<ScheduledReportDraft>(initialDraft)
|
||||||
|
const [groupFilter, setGroupFilter] = React.useState('')
|
||||||
|
|
||||||
|
const patch = (next: Partial<ScheduledReportDraft>): void =>
|
||||||
|
setDraft((current) => ({ ...current, ...next }))
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 时间的两个输入框保留**本地文本态**。
|
||||||
|
* 受控回写会在输入过程中把中间态规范化掉,用户就永远拼不出想要的数字;
|
||||||
|
* 规范化推迟到失焦。
|
||||||
|
*/
|
||||||
|
const [hourText, setHourText] = React.useState(() =>
|
||||||
|
String(parseScheduleTime(initialDraft.scheduleTime)[0]).padStart(2, '0')
|
||||||
|
)
|
||||||
|
const [minuteText, setMinuteText] = React.useState(() =>
|
||||||
|
String(parseScheduleTime(initialDraft.scheduleTime)[1]).padStart(2, '0')
|
||||||
|
)
|
||||||
|
|
||||||
|
const commitHour = (raw: string): void => {
|
||||||
|
const digits = raw.replace(/\D/g, '').slice(0, 2)
|
||||||
|
setHourText(digits)
|
||||||
|
if (digits === '') return
|
||||||
|
const value = Number(digits)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
patch({ scheduleTime: composeScheduleTime(value, parseScheduleTime(draft.scheduleTime)[1]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitMinute = (raw: string): void => {
|
||||||
|
const digits = raw.replace(/\D/g, '').slice(0, 2)
|
||||||
|
setMinuteText(digits)
|
||||||
|
if (digits === '') return
|
||||||
|
const value = Number(digits)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
patch({ scheduleTime: composeScheduleTime(parseScheduleTime(draft.scheduleTime)[0], value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 候选群 = 自动化现有的群列表 **并上** 当前规则已在用的来源群。
|
||||||
|
*
|
||||||
|
* ⚠️ `value` 一律是**稳定会话 id**(`xxx@chatroom`),不是群名 ——
|
||||||
|
* 群名会改,把群名当 id 存下来的规则改一次名就发错群。
|
||||||
|
* 迁移过来的老规则里可能仍是群名 / md5,这时作为"额外候选项"补进列表,
|
||||||
|
* 界面上用可读名展示,**绝不把裸标识印给用户**。
|
||||||
|
*/
|
||||||
|
const groupOptions = React.useMemo(() => {
|
||||||
|
const known = new Set(groups.map((group) => group.id))
|
||||||
|
const extras = Array.from(
|
||||||
|
new Set([initialDraft.group].filter((value) => Boolean(value) && !known.has(value)))
|
||||||
|
).map((value) => ({ id: value, name: resolveGroupDisplayName(value, groups) }))
|
||||||
|
return [...groups, ...extras]
|
||||||
|
}, [groups, initialDraft.group])
|
||||||
|
|
||||||
|
const visibleGroups = React.useMemo(() => {
|
||||||
|
const needle = groupFilter.trim().toLowerCase()
|
||||||
|
if (!needle) return groupOptions
|
||||||
|
return groupOptions.filter(
|
||||||
|
(group) =>
|
||||||
|
group.name.toLowerCase().includes(needle) || group.id.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
}, [groupOptions, groupFilter])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存前置校验。
|
||||||
|
*
|
||||||
|
* 不允许存一条跑不起来的规则:没有名称 / 没有来源群 / 选了「指定好友」
|
||||||
|
* 却没选人,这三种在运行期必然失败,与其让用户过几天在日志里发现,不如现在说清。
|
||||||
|
*/
|
||||||
|
const saveBlocker = !draft.name.trim()
|
||||||
|
? '请填写任务名称'
|
||||||
|
: !draft.group
|
||||||
|
? '请选择日报来源群'
|
||||||
|
: draft.targetType === 'contact' && !draft.targetContactId
|
||||||
|
? '请选择要发送的指定好友'
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const selectTargetType = (next: ScheduledReportTargetType): void => {
|
||||||
|
// 切换目标类型时清空已选联系人,避免看起来"已经选好了"却发不出去。
|
||||||
|
patch({
|
||||||
|
targetType: next,
|
||||||
|
targetContactId: next === 'contact' ? draft.targetContactId : ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="automation-editor">
|
||||||
|
<button type="button" className="automation-editor-back" onClick={onBack}>
|
||||||
|
← 返回定时日报
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<header className="automation-editor-header">
|
||||||
|
<div>
|
||||||
|
<h2>{draft.name.trim() || '新建定时日报'}</h2>
|
||||||
|
<p>按设定时间自动生成日报,并发送到指定微信会话。</p>
|
||||||
|
<div className="automation-leave-status-row">
|
||||||
|
<span className="automation-field-label">启用这条自动化</span>
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) => patch({ enabled: checked })}
|
||||||
|
aria-label="启用这条自动化"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="automation-editor-actions">
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onSave(draft)}
|
||||||
|
disabled={saving || Boolean(saveBlocker)}
|
||||||
|
title={saveBlocker || undefined}
|
||||||
|
>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{saveBlocker ? (
|
||||||
|
<p className="automation-notice warning" role="status" data-testid="scheduled-save-blocker">
|
||||||
|
{saveBlocker}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{legacyTarget ? (
|
||||||
|
<p className="automation-notice warning" role="status" data-testid="scheduled-legacy-target">
|
||||||
|
这条规则是升级前创建的,当时的发送目标({legacyTarget})无法自动对应到新的选项。
|
||||||
|
请重新选择发送目标后再保存。
|
||||||
|
</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>
|
||||||
|
<div className="automation-scheduled-time">
|
||||||
|
<Input
|
||||||
|
aria-label="执行时间小时"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={hourText}
|
||||||
|
onChange={(event) => commitHour(event.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
setHourText(String(parseScheduleTime(draft.scheduleTime)[0]).padStart(2, '0'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true">:</span>
|
||||||
|
<Input
|
||||||
|
aria-label="执行时间分钟"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={minuteText}
|
||||||
|
onChange={(event) => commitMinute(event.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
setMinuteText(
|
||||||
|
String(parseScheduleTime(draft.scheduleTime)[1]).padStart(2, '0')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-inline-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">预计下次执行</span>
|
||||||
|
<span className="automation-static-value" data-testid="scheduled-next-run">
|
||||||
|
{formatScheduledSlot(draft.scheduleTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- 2 · 完整日报配置(与旧「定时日报」对齐,一项不删)---------- */}
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>2 · 生成什么日报</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">任务名称</span>
|
||||||
|
<Input
|
||||||
|
aria-label="定时日报任务名称"
|
||||||
|
value={draft.name}
|
||||||
|
placeholder="例如:TraceMemo 每日晚报"
|
||||||
|
onChange={(event) => patch({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报来源</span>
|
||||||
|
<small>生成哪个群的日报。</small>
|
||||||
|
<RadioGroup
|
||||||
|
value={draft.group}
|
||||||
|
onValueChange={(value) => patch({ group: value })}
|
||||||
|
aria-label="日报来源"
|
||||||
|
className="automation-leave-contact-picker"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(event) => setGroupFilter(event.target.value)}
|
||||||
|
placeholder="搜索群聊"
|
||||||
|
aria-label="搜索群聊"
|
||||||
|
/>
|
||||||
|
<div className="automation-leave-contact-list">
|
||||||
|
{visibleGroups.length === 0 ? (
|
||||||
|
<p className="automation-group-empty">没有可选的群聊</p>
|
||||||
|
) : (
|
||||||
|
visibleGroups.map((group) => (
|
||||||
|
<div key={group.id} className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem value={group.id} id={`scheduled-source-${group.id}`} />
|
||||||
|
<label htmlFor={`scheduled-source-${group.id}`}>{group.name}</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报范围</span>
|
||||||
|
<Select
|
||||||
|
value={draft.reportRange}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({ reportRange: value as ScheduledReportDraft['reportRange'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="日报范围">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCHEDULED_REPORT_RANGE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 复用旧日报页的 MessageTypeSelector:类型 / 说明 /「至少选择一种」都来自真实定义。 */}
|
||||||
|
<MessageTypeSelector
|
||||||
|
value={draft.messageTypes}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(next) => patch({ messageTypes: next })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报内容</span>
|
||||||
|
<small>
|
||||||
|
与普通日报相同,固定生成今日话题、重要消息、问题与解答、实用资源、精彩对话、
|
||||||
|
活跃成员、活跃时间分布、关键词和内容密度。
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-inline-row automation-model-row">
|
||||||
|
<div>
|
||||||
|
<span className="automation-field-label">模型配置</span>
|
||||||
|
<small>使用当前日报默认的文字总结和图片理解模型。</small>
|
||||||
|
</div>
|
||||||
|
{onOpenModelSettings ? (
|
||||||
|
<Button variant="link" size="sm" onClick={onOpenModelSettings}>
|
||||||
|
更改模型
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报模板</span>
|
||||||
|
<Select
|
||||||
|
value={draft.templateId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({ templateId: value as ScheduledReportDraft['templateId'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="日报模板">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">成员名称</span>
|
||||||
|
<Select
|
||||||
|
value={draft.memberNameMode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({ memberNameMode: value as ScheduledReportDraft['memberNameMode'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="成员名称">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCHEDULED_REPORT_MEMBER_NAME_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">日报生成超时</span>
|
||||||
|
<div className="automation-scheduled-timeout">
|
||||||
|
<Input
|
||||||
|
aria-label="日报生成超时"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={String(draft.timeoutSeconds)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value.replace(/\D/g, '').slice(0, 4))
|
||||||
|
patch({ timeoutSeconds: Number.isNaN(next) ? draft.timeoutSeconds : next })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>秒</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---------- 3 · 发送目标四选一(不提供"指定其他群聊")---------- */}
|
||||||
|
<section className="automation-section">
|
||||||
|
<div className="automation-section-heading">
|
||||||
|
<h3>3 · 生成后发送到哪里</h3>
|
||||||
|
</div>
|
||||||
|
<p className="automation-section-lead">
|
||||||
|
生成哪个群的日报,与把日报发到哪里,是两件事。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={draft.targetType}
|
||||||
|
onValueChange={(value) => selectTargetType(value as ScheduledReportTargetType)}
|
||||||
|
aria-label="生成后发送到哪里"
|
||||||
|
className="automation-leave-targets"
|
||||||
|
>
|
||||||
|
{SCHEDULED_REPORT_TARGET_OPTIONS.map((option) => (
|
||||||
|
<div key={option.type} className="automation-leave-radio option">
|
||||||
|
<RadioGroupItem value={option.type} id={`scheduled-target-${option.type}`} />
|
||||||
|
<div className="automation-leave-radio-body">
|
||||||
|
<label htmlFor={`scheduled-target-${option.type}`}>{option.label}</label>
|
||||||
|
<small>{option.description}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{draft.targetType === 'contact' ? (
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">指定好友</span>
|
||||||
|
<LeaveNotificationTargetPicker
|
||||||
|
contacts={sendableContacts}
|
||||||
|
selectedId={draft.targetContactId}
|
||||||
|
onSelect={(id) => patch({ targetContactId: id })}
|
||||||
|
ariaLabel="指定好友"
|
||||||
|
searchPlaceholder="搜索好友"
|
||||||
|
emptyText="没有可发送的联系人"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 后置词:从「手动发送日报图片」那个输入框原样搬运过来,只是改成按规则存。 */}
|
||||||
|
<div className="automation-field">
|
||||||
|
<span className="automation-field-label">发送后置词</span>
|
||||||
|
<Input
|
||||||
|
aria-label="发送后置词"
|
||||||
|
value={draft.postfixText}
|
||||||
|
maxLength={SCHEDULED_REPORT_POSTFIX_MAX_LENGTH}
|
||||||
|
placeholder={DEFAULT_REPORT_IMAGE_POSTFIX_TEXT}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => patch({ postfixText: event.target.value })}
|
||||||
|
/>
|
||||||
|
<small>图片确认发送成功后,会发送一条文本消息;留空则只发图片。</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="automation-editor-footnote">
|
||||||
|
微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,
|
||||||
|
TraceMemo 本身不额外保存或转发内容。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="automation-editor-side">
|
||||||
|
{notificationSlot}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user