Compare commits

..
36 Commits
Author SHA1 Message Date
电摇小子 713232bd62 feat: 新增 API(capabilities / 退群监控 / 自动化 / 群统计) 2026-10-04 02:31:34 +07:00
电摇小子 e83e85fe64 feat: 持久化聊天记录导出设置 2026-10-02 19:37:09 +07:00
电摇小子 e5a1656644 style: 修复图标 样式 #46
Fixes https://github.com/Wxw-Gu/TraceMemo/issues/46
2026-10-01 17:35:47 +07:00
电摇小子 194dd0035b chore: 构建 2026-09-29 18:02:09 +07:00
电摇小子 c3629255ea chore: 更新二维码 2026-09-29 16:24:20 +07:00
电摇小子 345a0db49b chore: 提升版本 整理文档
整理档案首页按钮
2026-09-29 16:13:37 +07:00
电摇小子 5276bce060 chore: 打包配置 测试用例 2026-09-29 13:06:00 +07:00
电摇小子 0abbda1160 Merge branch 'develop_0920' into develop 2026-09-29 10:51:15 +07:00
电摇小子 97c74859c0 fix: 修复联系人 API 因昵称备注未补全导致搜索结果为空 #51
Fixes https://github.com/Wxw-Gu/TraceMemo/issues/51
2026-09-29 10:47:33 +07:00
电摇小子 4d5c6459fe feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
2026-09-28 17:11:50 +07:00
Wxw-Gu c835d5c570 feat: 定时日报归到定制化功能下 2026-09-25 17:44:45 +08:00
Wxw-Gu 59872daf52 feat: 退群监控接入自动化能力统一管理, 支持发送个人/文件传输助手/指定群聊 2026-09-24 16:31:07 +08:00
Wxw-Gu 3bd3935042 fix: 修复mac发送 初始化卡在'正在检查 发送运行时…' 2026-09-24 10:49:34 +08:00
Wxw-Gu 77a6912ade fix: 修复退群监控列表错误, 界面左侧滚动条 2026-09-24 10:36:10 +08:00
Wxw-Gu 4b83aaed61 Merge branch 'develop' into develop_0920
# Conflicts:
#	src/main/index.ts
#	src/preload/index.d.ts
#	src/preload/index.ts
#	src/renderer/src/styles/index.scss
2026-09-24 09:41:53 +08:00
Wxw-Gu 7268188e40 feat: mac发送能力重构为libtmwechat
- 发送改走本地host, 在微信登录时触发
- 删除旧OneBot及对应UI控制面
2026-09-24 03:01:03 +08:00
Wxw-Gu 069d836dea feat: 精确会话自动化规则 2026-09-21 11:06:59 +08:00
Wxw-Gu 95384a4a92 feat: 自动化 2026-09-21 00:35:32 +08:00
Wxw-Gu 9b82ca663b fix: 修复Windows语音消息取错 2026-09-21 00:33:02 +08:00
Wxw-Gu f5633a29cb feat: 新增群员统计与退群事件,并把本地索引集中到设置页
- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送
- 退群事件永久保存,并以本地推断条目并入档案消息流
- 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
2026-09-20 21:47:06 +08:00
Wxw-Gu 766cd94788 feat: 新增群员统计与退群事件,并把本地索引集中到设置页
- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送
- 退群事件永久保存,并以本地推断条目并入档案消息流
- 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
2026-09-20 21:46:37 +08:00
wuyouMaster b6d287a299 fix: make packaging checks platform safe 2026-09-20 18:16:41 +08:00
wuyouMaster 4c25236070 fix: ad-hoc sign packaged macOS key helpers and app bundle
electron-builder 26 skips macOS signing entirely when no Developer ID identity is configured, so the packaged key helpers can ship unsigned or with a broken signature and macOS kills them even with SIP disabled. afterPack now verifies and ad-hoc re-signs the packaged xkey helpers and the outer app bundle, failing the build when a signature cannot be repaired.
2026-09-20 18:16:41 +08:00
qingmao c1224cb612 Merge pull request #45 from mmhh256/fix/query-agent-system-messages
fix: 合并 Query Agent 的连续 system messages
2026-09-20 16:11:08 +08:00
Wxw-Gu 5036639aa1 test: 测试用例 2026-09-20 15:56:27 +08:00
Wxw-Gu 9593ca0f54 fix: 修复语音消息取错、时长精度与转写不刷新
- 时长解析:改取 <voicemsg voicelength>(毫秒),不再误取 length(SILK 字节数)
- 时长显示:四舍五入对齐微信口径,有原生时长时不被解码时长覆盖
- 转写:重新识别改为强制重算,跳过身份级缓存短路(音频级缓存仍生效)
- 日报:语音累计秒数显示取整,不再出现小数
2026-09-20 15:40:01 +08:00
mmhh256 b441287d54 fix: 合并 Query Agent 的连续 system messages 2026-09-19 14:01:39 +08:00
Wxw-Gu 9b82d29037 fix: 兼容微信新版系统消息模板格式,入群通知不再显示成「撤销」 2026-09-18 14:41:50 +08:00
Wxw-Gu 0f270366aa feat: 图片文字索引按时间分段优先处理最近图片
- 首次索引先处理最近 7 天,再依次回溯 30 天 / 近一年 / 更早历史
 - 完成后新到的图片单独补齐,不受历史回填影响
 - 覆盖度增加时间维度,可区分「最近已完整」与「更早仍在补齐」
2026-09-18 14:39:05 +08:00
Wxw-Gu 12b8fb34c6 fix: 增加sip文案 2026-09-18 09:38:33 +08:00
Wxw-Gu 774346d503 fix: 日报头像补齐下载重试 2026-09-17 19:24:27 +08:00
Wxw-Gu b97e1f4bc6 fix: 问问微信恢复引用编号,日报修复成员头像与 hero 溢出
- 问问微信:Host 分配稳定 citationId 并注入模型上下文,正文 [E#] 与证据按钮同号
- 问问微信:回答返回前校验引用,幻觉编号移除并提示,不再出现不可信的引用按钮
- Agent Hub:收紧「最近会话」快捷路由,内容查询交回 Query Agent
2026-09-17 18:56:32 +08:00
Wxw-Gu 4b2395a8d2 feat: 重写agent hub连接器,新增对话记录和微信输入状态
- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容
- 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
2026-09-17 17:32:37 +08:00
Wxw-Gu 1337bcb7de feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度
- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
2026-09-17 13:11:20 +08:00
电摇小子 b8f08d54c8 feat: 新增微信图片文字索引与问问微信图片检索能力 2026-09-16 10:42:52 +08:00
电摇小子 24399f1d70 feat: 新增 Windows 本地图片文字识别能力 2026-09-16 00:23:23 +08:00
388 changed files with 59995 additions and 12610 deletions
-8
View File
@@ -25,11 +25,6 @@ jobs:
node-version: 22
cache: pnpm
- uses: actions/setup-go@v5
with:
go-version-file: services/wechat-connector/go.mod
cache-dependency-path: services/wechat-connector/go.sum
- name: Install dependencies
run: pnpm install
@@ -51,9 +46,6 @@ jobs:
- name: Skill installation instruction tests
run: pnpm test:skill-install
- name: WeChat connector tests
run: pnpm test:wechat-connector
- name: Build Electron test application
run: pnpm test:e2e:build
+2 -2
View File
@@ -10,8 +10,8 @@ out
coverage/
playwright-report/
test-results/
resources/connectors/wechat/
resources/connectors/wechat-personal/
resources/runtime/darwin-arm64
.native-runtime-source
.omc
.codex/
skills-lock.json
+30 -20
View File
@@ -25,16 +25,14 @@
</p>
<p align="center">
<img src="./public/日报.png" alt="TraceMemo 主界面" />
<img src="./public/日报.png" alt="TraceMemo 日报" />
</p>
<p align="center">
<img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
<img src="./public/自动化.png" alt="TraceMemo 自动化" />
</p>
<p align="center">
<img src="./public/退群监控.png" alt="TraceMemo 退群监控" />
</p>
---
## 🎨 社区日报模板
@@ -49,7 +47,7 @@ TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多
在 TraceMemo 中打开:
**日报 → 今日日报 → 日报模板 → 模板市场**
**日报 → 社区模板市场**
即可查看、预览、安装和切换已发布的社区模板。
@@ -67,18 +65,21 @@ TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数
## 核心能力
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
- 💬 **聊天档案与搜索**:浏览会话,按关键词、备注、昵称或 wxid 查找消息。
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
- 🧠 **本地知识库**:在本机建立索引,让跨会话、跨时间的查询更稳定。
- 🖼️ **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图),识别结果可以在搜索和「问问微信」里被检索。识别全程不联网,原始图片不会因为本地识别而上传。
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结,可保存为 HTML 与 PNG。
- 🗣️ **群发言统计**:统计群成员的发言量和沉默成员,看清一个群里谁在说、谁一直没说。
- 👀 **退群监控**:用成员快照对比记录群成员退出事件,支持多群与事件历史。
- ⚙️ **自动化**:把上面几步按规则串起来——定时生成并发送日报、成员退群时发送通知;能发到哪里取决于当前的发送能力。
- 🔊 **文字转语音**:把文字生成语音,试听后发送到当前会话。
- 🤖 **Agent Hub**:在微信里向本机 TraceMemo 提问。
- 🔌 **外部 Agent / Local HTTP API**:让 Codex 等外部 Agent 查询本机微信历史。
## 💻 平台支持
TraceMemo 2.4.0 支持:
TraceMemo 2.5.0 支持:
- **Windows x64**
- **macOS Apple Silicon(M 系列 / arm64)**
@@ -86,6 +87,12 @@ TraceMemo 2.4.0 支持:
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
### 关于“发送能力”
浏览、搜索、日报生成、导出、知识库和图片文字索引都不需要额外的发送组件。只有**把内容真正发回微信**这一步——自动发送日报、退群通知、把语音发到会话——依赖本机发送能力:
发送能力未就绪、未绑定或发送失败时,报告本身仍会正常生成并保存在本机,执行记录会显示为“已生成,但未发送”或“已生成,发送失败”,可以稍后重试。
## 项目缘起
<details>
@@ -138,11 +145,14 @@ TraceMemo 最早叫 **WechatExplorer**。
| 想做什么 | 使用入口 |
| ---------------------------------- | ----------------------------- |
| 找记得原文或关键词的消息 | 档案搜索 |
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
| 找记得大意、但不知道在哪聊过的内容 | 问问微信(AI Search) |
| 找到截图、公告图里写过的文字 | 问问微信 → 图片文字索引 |
| 长期跨群查询历史 | 本地知识库 |
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
| 了解一个群今天或近 7 天聊了什么 | 日报 |
| 看群里谁最活跃、谁一直没说话 | 档案 → 群聊 → 群发言统计 |
| 持续关注群成员退出 | 退群监控 |
| 按计划生成并发送群聊日报 | 定时日报 |
| 按计划自动生成并发送群聊日报 | 自动化 |
| 成员退群时自动发一条通知 | 自动化 → 退群通知 |
| 把文字生成微信语音 | 文字转语音 |
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
@@ -159,9 +169,9 @@ TraceMemo 最早叫 **WechatExplorer**。
## 文档
- [用户指南](./docs/README.md#用户指南)
- [用户指南](./docs/README.md#档案与搜索)
- [AI / Knowledge](./docs/README.md#ai-与知识库)
- [Monitor / Automation](./docs/README.md#日报与自动化)
- [日报与自动化](./docs/README.md#日报与自动化)
- [Agent / API](./docs/README.md#agent--api)
- [开发文档](./docs/development/overview.md)
- [隐私与安全](./docs/user-guide/privacy.md)
@@ -213,7 +223,7 @@ TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.co
这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
+9 -8
View File
@@ -6,29 +6,29 @@
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口,以及群聊里的「群发言统计」。
## AI 与知识库
- [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性;也包括在本机识别图片文字、让截图和公告图变得可搜索的「图片文字索引」。
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
## 日报与自动化
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
- 定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以在「自动化」里创建定时日报。
- 「自动化」按三类规则执行:**@我生成日报**、**定时日报**、**退群通知**。定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./concepts/how-it-works.md#动作执行与审计)。
## Monitor
## 退群监控
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。成员退出同时是「自动化 → 退群通知」的触发条件。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
## 语音能力
- [语音转文字](./user-guide/voice.md):在本机转写微信语音,结果可用于搜索、Knowledge 和导出。
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊。
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊;实际发送依赖本机发送能力。
## Agent / API
@@ -49,6 +49,8 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
## 开发文档
- [开发、测试与构建](./development/overview.md)
- [界面开发规范:按钮与主题色](./development/ui-guidelines.md)
- [微信系统消息解析与格式兼容](./development/wechat-system-message-parsing.md)
- [Query Agent POC(开发测试入口)](./development/query-agent-poc.md)
- [本地启动排障](./development/local-startup-troubleshooting.md)
- [macOS 数据访问说明](./platform/macos.md)
@@ -59,7 +61,6 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md)
## 版本说明
+12 -11
View File
@@ -21,14 +21,15 @@ Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导
- “帮我看看最近跟某人聊了些什么。”
- “生成产品交流群今天的群聊总结图片。”
当前已实现的实时任务包括:
Hub 把入站文字分成两类处理。
- 查看最近会话(数量限制为 1–20);
- 查询你和某位联系人的近期聊天;
- 用已配置的 AI 总结你和某位联系人近 7 天的聊天;
- 生成今天、昨天或近 7 天的群聊总结图片;
- 总结指定群成员在群里的近期发言;
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
**确定性的快捷动作**(不经过模型,命中就执行):
- 查看最近会话:数量限制为 1–20;
- 生成群聊总结图片:今天、昨天或近 7 天(需要同时提到“群”和“图片 / 长图 / 日报 / 报告”);
- 分析某个群成员的近期发言:可以指定“今天 / 昨天 / 最近 N 天”。
**其余问题**交给本机的 Query Agent:它和桌面端“问问微信”使用的是同一个实现,可以按需读取联系人、会话和时间范围来回答,必要时调用你在“设置 → AI 模型”里配置的 AI。例如“帮我看看最近跟某人聊了些什么”“上个月讨论过的项目地址在哪里”。
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
@@ -56,12 +57,12 @@ Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支
## 安全与边界
- Hub 使用本机通信,不把数据库直接暴露到公网;
- Hub 在主进程内运行,不开放本地监听端口;它不会把数据库暴露到公网;
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider;
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
- Hub 理解请求或生成总结时,会调用你在“设置 → AI 模型”配置的 Provider;
- 当前实时入口只处理文字消息。连接器会归一化收到的消息条目,但 Agent Hub 只把文本条目当作意图处理,尚未为图片、语音、文件和视频提供同等能力;
- 当前没有实现群发、广播、定时任务或通用自主操作微信(定时日报属于「自动化」,不是 Agent Hub);
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
## 无法连接时
+5 -2
View File
@@ -4,9 +4,11 @@
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
它同时包含**写入型**端点:生成报告并渲染 PNG(`/report`)、通过已连接机器人发送微信消息(`/agent/send`)、创建/修改/删除/启停定时日报任务并触发立即执行(`/scheduled-reports*`)。因此这个 Token 相当于本机敏感凭据,而不是一个只读查询键。
## Bearer Token
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。历史变量名 `WECHATEXPLORER_API_TOKEN` 仍被兼容读取,优先级为新变量高于旧变量;当前没有设定旧变量名的移除时间,新配置不要再使用它。
- `/api/v1/health` 是公开健康检查;
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
@@ -14,7 +16,8 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
- 文件权限设置为 `0600`;
- 在“API Center”中可以显示、复制和重新生成;
- 重新生成后旧 Token 立即失效。
- 重新生成后旧 Token 立即失效;
- 服务端只认这个 Token,**不接受用环境变量覆盖**——Agent 一侧的环境变量只是把 Token 交给 Agent 自己的方式,不是鉴权来源。
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
+146 -5
View File
@@ -8,7 +8,9 @@
- API 前缀:`/api/v1`
- 默认只监听 loopback;不要把它当作公网服务。
- `/api/v1/health` 无需 Token;其他端点需要 `Authorization: Bearer <TOKEN>`。
- 请求体使用 JSON;响应为 JSON。
- 请求体使用 JSON,单个请求体最大 `1 MiB`;超限返回 `413`。
- 错误响应包含 `requestId`,响应头包含 `X-Request-Id`。客户端可传入 1-128 位的 `[A-Za-z0-9._:-]` 标识,否则服务端会生成 UUID。
- 不支持的 HTTP method 返回 `405` 和 `Allow` 响应头。
## 最小请求
@@ -24,12 +26,14 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。应用生成的安装指令仍会提示:尚未升级的旧配置可以继续读取 `WECHATEXPLORER_API_TOKEN`,但新配置必须使用新变量名;如果两个变量都存在,以新变量为准。当前没有设定旧变量名的移除时间。
Token 由应用生成并保存在本机,**不接受用环境变量覆盖**:Agent 侧的环境变量只是把 Token 传给 Agent 自己的方式,不是服务端的鉴权来源。
## 端点
| 方法 | 路径 | 作用 | 参数/请求体 |
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| ------ | --------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------- |
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
@@ -43,12 +47,147 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
| GET | `/api/v1/wechat-personal/send-capability` | 个人微信发送能力状态 | 无 |
| GET | `/api/v1/scheduled-reports` | 定时日报任务列表 | 无 |
| POST | `/api/v1/scheduled-reports` | 创建定时日报任务 | `ScheduledReportApiCreateRequest` JSON |
| GET | `/api/v1/scheduled-reports/{id}` | 查询单个定时日报任务 | 无 |
| PATCH | `/api/v1/scheduled-reports/{id}` | 修改定时日报任务 | `ScheduledReportApiUpdateRequest` JSON |
| DELETE | `/api/v1/scheduled-reports/{id}` | 删除定时日报任务 | 无 |
| POST | `/api/v1/scheduled-reports/{id}/enable` | 启用定时日报任务 | 无 |
| POST | `/api/v1/scheduled-reports/{id}/disable` | 暂停定时日报任务 | 无 |
| POST | `/api/v1/scheduled-reports/{id}/run` | 立即执行一次并返回 execution | 无 |
| GET | `/api/v1/scheduled-reports/{id}/executions` | 查询某个任务的执行记录 | 无 |
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 兼容占位路由;当前返回 `501 not_supported` | 无 |
| GET | `/api/v1/capabilities` | TraceMemo 应用能力和可用状态 | 无 |
| GET | `/api/v1/automations` | 自动化规则列表 | 可选 `type`、`enabled` |
| POST | `/api/v1/automations` | 创建默认停用的自动化规则 | Automation draft JSON |
| POST | `/api/v1/automations/validate` | 校验规则,不保存、不执行 | Automation draft JSON |
| GET | `/api/v1/automations/{id}` | 查询单条自动化规则 | 无 |
| PATCH | `/api/v1/automations/{id}` | 更新规则配置 | 可变配置字段 JSON |
| DELETE | `/api/v1/automations/{id}` | 删除自定义规则 | 系统内置规则受保护 |
| POST | `/api/v1/automations/{id}/enable` | 校验并启用规则 | 无 |
| POST | `/api/v1/automations/{id}/disable` | 停用规则 | 无 |
| GET | `/api/v1/automations/executions` | 查询自动化执行记录 | `ruleId`、`status`、`since`、`until`、`limit` |
| GET | `/api/v1/monitors/group-exits` | 查看退群监控状态 | 无 |
| PATCH | `/api/v1/monitors/group-exits` | 配置监控群范围或启停 | `enabled`、`monitoredConversationIds` |
| GET | `/api/v1/monitors/group-exits/events` | 查询退群事件历史 | `conversationId`、`since`、`until`、`limit` |
| GET | `/api/v1/groups/{conversationId}/member-stats` | 查询群成员活跃统计 | 必填 `conversationId`、`start`、`end` |
`/api/v1/query/*` 是一组结构化的 Query 端点,见下方[LLM-friendly Query Tool API](#llm-friendly-query-tool-api)。
## Application Capabilities
`GET /api/v1/capabilities` 描述 TraceMemo 应用级能力和当前运行环境;`GET /api/v1/query/capabilities` 只描述结构化 Query primitive,两者不是同一份目录。应用能力使用 `supported` 和 `available` 分开表示“代码支持”与“当前可用”;运行时原因使用稳定的简短 code,不返回 Token、数据库路径、微信密钥或 sender 诊断路径。
响应包含应用版本、数据库 readiness、Query、Automation、退群监控、群统计,以及个人微信/iLink 的能力状态。`groupExitMonitor.operations` 当前声明 `read_state`、`configure_scope`、`enable`、`disable`、`list_events`;`groupStats.operations` 当前声明 `member_stats`。能力声明不会触发监控扫描或群统计查询。
```bash
: "${TRACEMEMO_API_TOKEN:?Set TRACEMEMO_API_TOKEN from API Center}"
BASE="http://127.0.0.1:6131/api/v1"
AUTH="Authorization: Bearer $TRACEMEMO_API_TOKEN"
curl -H "$AUTH" "$BASE/capabilities"
```
## Group Exit Monitor API
退群监控只负责“监测哪些群、发现了哪些退群事实”。退群后是否通知、通知到哪里以及通知模板,仍由 `leave_notification` Automation singleton 负责;修改监控范围不会隐式修改该 Automation。
### 查看和配置监控
```bash
curl -H "$AUTH" "$BASE/monitors/group-exits"
curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
"$BASE/monitors/group-exits" \
-d '{"enabled":true,"monitoredConversationIds":["123@chatroom"]}'
```
`monitoredConversationIds` 只接受当前联系人列表中精确存在的群 `roomId`(例如 `xxx@chatroom`),不接受群名、md5、个人联系人、重复或空 ID。请求至少提供 `enabled` 或 `monitoredConversationIds` 其中一个;传空数组表示清空监控范围。服务会先校验全部群,再执行一次原子配置。PATCH 返回最终完整状态。
状态中的 `eventCount` 是持久化退群事件总数,`lastCheckedAt`/`lastReadAt` 为空时返回 `null`。GET 不会调用 `checkNow()`,也不会触发通知发送。
### 查询退群事件
```bash
curl -G -H "$AUTH" "$BASE/monitors/group-exits/events" \
--data-urlencode 'conversationId=123@chatroom' \
--data-urlencode 'since=2026-10-01T00:00:00+07:00' \
--data-urlencode 'until=2026-10-02T23:59:59+07:00' \
--data-urlencode 'limit=50'
```
时间参数必须是带 offset 的 ISO-8601;默认 `limit=50`,最大 200。事件按 `detectedAt` 升序返回。事件 DTO 使用 `eventId`、稳定的 `conversationId` 和 `memberId`,并把时间输出为 ISO-8601;当前整体已读状态不会伪造成 event-level `read` 字段。当前未开放 clear events、markRead 或 checkNow HTTP 路由。
一个典型 Agent 工作流是:先通过 `/resolve` 或 `/contact` 找到稳定群 ID,再 PATCH monitor scope;如需通知,再单独 PATCH `leave_notification` Automation,调用 `/automations/validate`,最后启用规则。
## Group Member Stats API
```bash
curl -G -H "$AUTH" "$BASE/groups/123%40chatroom/member-stats" \
--data-urlencode 'start=2026-09-01T00:00:00+07:00' \
--data-urlencode 'end=2026-10-01T00:00:00+07:00'
```
`conversationId` 必须是当前联系人列表中精确存在的群 `roomId`;不存在返回 `NOT_FOUND`,个人联系人返回 `NOT_GROUP_CONVERSATION`。`start` 和 `end` 必须同时提供,且使用带 offset 的 ISO-8601,`start` 不能晚于 `end`。HTTP adapter 只负责把稳定群 ID 解析为内部 md5 并调用现有 `GroupStatsService`,不会在 HTTP 层重新统计消息。
响应中的 `activeMembers` 和 `silentMembers` 都只描述当前成员名单;成员使用 `memberId`,活跃成员的 `lastMessageAt` 和 `range` 时间均为 ISO-8601。`freshness`、`complete`、`limitations` 必须原样保留,`unattributedMessages` 与 `excludedSystemMessages` 用于诊断,`firstMessageAt` 没有消息时为 `null`。当前成员统计不等于完整历史成员统计,`limitations` 表达的退群成员或未归档时段不能从文本中推导成额外的 `formerMembers`,也不会伪造 `totalMessageCount`。
## Automation API
`/api/v1/automations*` 是 Automation 的 canonical HTTP API,读写唯一的 `AutomationRuleStore`。它支持当前真实规则类型:`daily_report`、`scheduled_report`、`leave_notification`。本 API 不提供立即执行、重试或清理执行记录。
旧 `/api/v1/scheduled-reports*` 保持兼容,不设移除日期;它是面向旧 DTO 的受限 compatibility API,不是第二份存储,也不能表示所有新的定时日报目标和配置。新的 Agent 集成应使用 `/automations`。
创建和校验规则时 `enabled` 只能缺省或为 `false`。创建成功后必须调用 `/automations/{id}/enable` 才会启用。启用会重新校验当前规则;数据库未就绪、目标无法解析或配置无效时不会启用。`PATCH` 只接受规则配置字段,不可改 `ruleType`、`id`、创建/更新时间或 `enabled`;启停必须使用独立 endpoint。未知字段和未知枚举会被拒绝。
会话范围优先传 `wxid`、`roomId`(如 `xxx@chatroom`)或 canonical conversation ID。唯一匹配的联系人名可被解析为稳定 ID;重名会返回 `ambiguous_contact`,不会猜测。`daily_report.conditions.conversationIds` 在对外 API 中使用稳定 ID,Store 内部仍沿用既有 md5 口径。
校验请求不落盘、不发消息,也不执行规则。`valid: false` 时查看 `issues`;有效时 `normalized` 是经 ID 解析后的草稿,`effects` 描述启用后的动作,定时日报另外返回按本机时区计算的 `nextRunAt`。
```json
{
"name": "产品群每日日报",
"ruleType": "scheduled_report",
"scheduledReport": {
"schedule": { "time": "20:00" },
"report": {
"sourceConversationId": "wxid_product@chatroom",
"range": "today",
"messageTypes": ["text", "image"],
"templateId": "v1",
"memberNameMode": "groupNickname",
"timeoutSeconds": 300
},
"target": { "type": "file_transfer" },
"postfixText": ""
}
}
```
执行历史只读,默认最多返回 50 条,`limit` 范围是 1-200。`since` 和 `until` 接受带时区的 ISO-8601 时间;execution 本身最多留存 200 条。`running` 记录的 `finishedAt` 为 `null`。
新 Agent API 的错误格式:
```json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "自动化规则校验失败",
"details": []
},
"requestId": "..."
}
```
常见错误码包括 `UNAUTHORIZED`、`METHOD_NOT_ALLOWED`、`PAYLOAD_TOO_LARGE`、`INVALID_ARGUMENT`、`NOT_FOUND`、`NOT_GROUP_CONVERSATION`、`DATABASE_NOT_READY`、`VALIDATION_FAILED`、`SINGLETON_RULE`、`PROTECTED_RULE` 和 `PERSISTENCE_FAILED`。`leave_notification` 是固定单例:可读取、修改和启停,但不能创建第二条或删除。内置 `@我生成日报` 同样不能通过 HTTP 删除。
### 这些端点与实时机器人有什么关系
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
- `/api/v1/agent/group-report` 由外部 Agent 或脚本主动请求生成群聊总结图片;
- `/api/v1/agent/send` 是受 Bearer Token 保护的开发者/测试发送入口,用于通过已经连接的机器人发送文字或本地图片;它不是任意群发能力,也不是实时消息订阅接口;
- `/api/v1/scheduled-reports*` 会**写入**应用状态:创建、修改、删除、启停定时日报任务,以及立刻执行一次。加上 `/report` 和 `/agent/send`,这个 API 并非只读接口——拿到 Token 就能改配置、生成报告并发送微信消息,请按本机敏感凭据对待;
- `POST /api/v1/scheduled-reports/{id}/run` 与定时触发共用同一条链路:读取群聊 → 生成报告 → 保存 Report History → 尝试发送;
- 当前 API 没有对外暴露实时入站 webhook。微信消息由应用内部的 Agent Hub 和微信连接器接收、处理和回复。
## 时间查询
@@ -83,10 +222,12 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
## 响应与错误
- `200`:请求成功;
- `201`:定时日报任务创建成功;
- `401`:缺少、错误或已失效的 Bearer Token;
- `400`:参数或 JSON 请求体无效;
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
- `403`:浏览器 Origin 不在允许的 loopback 列表;
- `409`:定时日报任务重复(`error === "duplicate"`,响应里会带回已存在的任务),或群聊名称匹配到多个目标(`ambiguous_contact`);
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
- `503`:数据库或 Agent Hub 尚未就绪;
- `500`:服务端处理或报告渲染失败。
@@ -155,7 +296,7 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
| scope | 含义 |
| ----- | ---- |
| ----------------------------------------- | -------------------------------------------------------------------- |
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
@@ -187,7 +328,7 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
| 字段 | 含义 |
| ---- | ---- |
| ------------------- | ------------------------------------------------------------------- |
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
+6 -1
View File
@@ -8,7 +8,7 @@ Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Cod
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量。
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 仍可继续使用旧变量 `WECHATEXPLORER_API_TOKEN`(当前没有设定移除时间),但新安装请使用新名称与新变量名。
## 推荐安装流程
@@ -53,8 +53,13 @@ Reader Skill 可以指导 Agent 使用:
- 指定会话、日期或时间戳范围的聊天记录;
- 群成员快照;
- 结构化日报渲染和按群聊生成总结图片;
- 定时日报任务的查询、创建、修改、启停、删除、立即执行和执行记录;删除不可逆,Skill 要求先列出唯一任务并取得用户明确确认;
- 个人微信发送能力状态查询(`/wechat-personal/send-capability`);
- `query/*` 一组结构化 Query 端点:`messages`、`search`、`message-context`、`conversation-overview`;
- Agent Hub 状态检查与已连接机器人发送测试。这里的发送接口是开发者/测试用途,不是实时机器人入口,也不会让 Reader Skill 自动监听微信消息。
注意这个 API 不只是只读的:`/report`、`/agent/send` 和 `/scheduled-reports*` 会写入状态或真的发出微信消息。
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
## 隐私边界
+50 -28
View File
@@ -4,39 +4,54 @@
```mermaid
flowchart LR
A[本机微信数据] --> B[读取与解析]
B --> C[聊天档案与普通搜索]
B --> D[本地知识索引]
D --> E[筛选相关消息]
E --> F[用户配置的 AI Provider]
F --> G[回答与可核对来源]
B --> H[聊天导出]
B --> I[整理日报输入]
I --> F
F --> J[本地保存 HTML 与 PNG]
B --> K[Local HTTP API]
K --> L[外部 Agent]
M[微信机器人消息] --> N[Agent Hub]
N --> B
N --> F
B --> O[Monitor / Snapshot]
O --> P[Proposed Action]
F --> P
P --> Q[Policy]
Q --> R[Action Gateway]
R --> S[Personal WeChat Send Capability]
S --> T[Action Audit / Logs]
WX["本机微信数据"] --> PARSE["读取与解析"]
PARSE --> ARCHIVE["聊天档案与普通搜索"]
PARSE --> EXPORT["聊天导出"]
PARSE --> IDX["本机索引"]
IDX --> TEXTIDX["聊天记录索引"]
IDX --> IMGIDX["图片文字索引(本机识别)"]
TEXTIDX --> UNDERSTAND["Understand:AI Search / 问问微信"]
IMGIDX --> UNDERSTAND
UNDERSTAND --> PROVIDER["你配置的 AI Provider"]
PROVIDER --> ANSWER["回答与可核对来源"]
PARSE --> REPORTINPUT["整理日报输入"]
REPORTINPUT --> PROVIDER
PROVIDER --> REPORTFILE["本机保存 HTML 与 PNG"]
PARSE --> MONITOR["Monitor:退群监控 / 成员快照"]
MONITOR --> RULE["自动化规则"]
REPORTFILE --> RULE
RULE --> POLICY["Policy"]
POLICY --> GATEWAY["Action Gateway"]
GATEWAY --> CAP["本机发送能力"]
CAP --> AUDIT["执行记录与审计"]
PARSE --> API["Local HTTP API"]
API --> EXTAGENT["外部 Agent / Reader Skill"]
BOT["微信机器人消息"] --> HUB["Agent Hub"]
HUB --> PARSE
HUB --> PROVIDER
```
## Remember → Understand → Monitor → Act
## Remember → 图片文字 → Understand → Monitor → Act
TraceMemo 的工作方式可以概括为:
```text
Remember → Understand → Monitor → Act
Remember → 图片文字 → Understand → Monitor → Act
```
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
- **Remember**:读取并解析本机微信数据,建立聊天档案、普通搜索和导出。
- **图片文字**:在本机识别图片里的文字,把截图、公告、报价图也变成可检索的内容。这一步不联网。
- **Understand**:Knowledge、AI Search / 问问微信、群聊日报。需要模型时,只把完成这次任务所需的受控上下文交给 Provider。
- **Monitor**:用成员快照对比发现群成员变化,产出成员退出事件。
- **Act**:自动化规则把前面的步骤串起来(定时日报、退群通知);动作经过统一执行边界,并留下执行记录。
回答和动作结果都应能回到来源或记录核对。
## 退群监控
@@ -46,7 +61,9 @@ Remember → Understand → Monitor → Act
Current Membership → Snapshot Diff → Member Event
```
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。监控关闭期间发生的变化,不会在重新开启后补报。
成员退出事件同时是「自动化」里「退群通知」规则的触发条件。
## 动作执行与审计
@@ -58,17 +75,20 @@ Feature → Policy → Gateway → Capability → Execution → Audit
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
这些动作统一由「自动化」管理,当前有三类规则:**@我生成日报**、**定时日报**、**退群通知**。发送目标支持当前群聊、文件传输助手、自己、指定好友,不是任意群发。
## 哪些步骤在本机
- 微信数据库读取与解析;
- 聊天档案浏览和普通搜索;
- Knowledge 索引与增量同步;
- 图片文字索引:识别图片中的文字完全在本机进行,原始图片不会因为本地识别而上传;
- 离线语音转写;
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
## 哪些步骤可能调用外部服务
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库,本机 OCR、离线语音转写和普通搜索也不会触发外发。
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
@@ -77,11 +97,13 @@ Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生
## 产品名词和用户任务的对应关系
| 用户想做什么 | 产品中可能看到的名称 |
| ------------------------ | ---------------------------- |
| ------------------------------ | ---------------------------- |
| 让 AI 找相关聊天 | AI Search、Retrieval |
| 让答案能回到原消息 | Evidence、Citation |
| 查看 AI 查找过程 | Search Trace |
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
| 搜到截图、公告图里写过的文字 | 图片文字索引、本机 OCR |
| 让日报、退群通知按规则自动执行 | 自动化、Policy、执行记录 |
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
| 让微信机器人调用本机能力 | Agent Hub |
@@ -0,0 +1,308 @@
# TraceMemo Local HTTP API / Agent API 能力审计
本报告基于当前代码、文档、IPC、Renderer 调用和相关测试做静态审计,对应当前发布应用版本 `2.5.0`;不连接真实微信数据库,也不修改生产代码、API Center 或测试。
主要代码入口:[http-server.ts](../../src/main/http-server.ts)、[automation-rule-store.ts](../../src/main/services/automation-rule-store.ts)、[group-exit-monitor-service.ts](../../src/main/services/group-exit-monitor-service.ts)、[group-stats-service.ts](../../src/main/services/group-stats-service.ts)、[api.md](../agent/api.md)。
## 1. Executive Summary
当前 HTTP 层声明了 **30 个 Method + Path 模板**:15 个 GET、12 个 POST、1 个 PATCH、1 个 DELETE、1 个 HEAD。共享 `LOCAL_API_ENDPOINTS` 定义 **14 个测试项**,但 Renderer 的 `API_ENDPOINTS` 实际只展示 **12 项**;个人微信能力和 `GET /scheduled-reports` 虽有共享定义,界面没有展示。界面也没有结构化 Query、媒体读取、定时日报写操作、退群监控、群统计、Automation 通用资源或执行日志查询。
当前不缺成熟的 Query primitive:结构化读取消息、Knowledge 搜索、消息前后文、会话概览和图片 OCR 搜索已经有 HTTP 契约。真正的能力缺口集中在 **监控配置、自动化规则通用管理、群员统计、执行历史、全局能力发现、发送状态和报告历史**。
几个影响后续设计的代码事实:
1. 定时日报已迁入 `AutomationRuleStore`。当前 `/scheduled-reports` 是旧 HTTP 契约的兼容投影,不是第二份活动规则存储;但它只表达“生成后发回来源群”,其他当前合法目标不会出现在该兼容 API 列表中。
2. 退群监控只负责监测范围、快照和事件历史;退群后如何通知由唯一的 `leave_notification` Automation 规则负责。二者应分别建资源。
3. 群统计 Service 足以提供活跃成员、当前沉默成员、时间范围、新鲜度和限制说明;它没有结构化的 former member 列表,也没有覆盖所有发言者的群总消息数。
4. 现有微信 Action Gateway 有策略、审计和幂等骨架,但策略目前主要校验收件人,以及 Automation purpose allowlist;手动用户 purpose 默认可放行。它只发个人微信,现有 `/agent/send` 则走 iLink 的 `WechatSendGateway`。两者不能直接视为统一的 Agent 安全边界。
5. HTTP 静态 GET handler 大多没有 method guard;对这些路径发 POST、PATCH 或 DELETE 仍会执行读处理。Automation store 的规则落盘失败会记录日志,但仍将内存中的规则返回为成功。
建议先做应用级能力发现、退群监控资源、严格校验后的 Automation CRUD/执行查询;发送和“立即执行”放到有 dry-run、明确确认、幂等键和 Action 审计的后续阶段。
## 2. Current API Inventory
以下按 `http-server.ts` 的路由分派和动态 route factory 盘点。静态读路由的 `Method` 是当前文档和产品语义的预期方法;实际接受方法的差异见本节末尾。
| Method | Path | 能力 | Read/Write/Execute | Service | API Center | 文档 | Agent 价值 |
|---|---|---|---|---|---|---|---|
| GET | `/api/v1/health` | HTTP 与数据库 ready 状态;唯一免 Token 路径 | Read | `isReady()` | 是 | 是 | 高:连通性 |
| GET | `/api/v1/current_time` | 本机时间、时区、日期 | Read | JavaScript `Date` | 是 | 是 | 中:相对日期换算 |
| GET | `/api/v1/contact` | 联系人和群列表;`filter`、`type`;使用异步 hydration | Read | `chat-service.listContactsAsync` | 是 | 是 | 高:标识发现与 resolve 前置 |
| GET | `/api/v1/chatroom` | 群聊列表;`keyword`;使用异步 hydration | Read | `chat-service.listContactsAsync` | 是 | 是 | 高:群标识发现 |
| GET | `/api/v1/recent_chat` | 最近会话;`limit` 默认 50 | Read | `chat-service.listRecentChat` | 是 | 是 | 高:导航/摘要 |
| GET | `/api/v1/chatlog` | 按 talker 和时间读取原始消息;移除 `contentData.aeskey` | Read | `chat-service.listMessages`、`resolveMd5` | 是 | 是 | 高,但旧式、未做结构化分页 |
| GET | `/api/v1/group_snapshot` | 群成员快照;必填 `md5` | Read | `chat-service.getGroupSnapshot` | 是 | 是 | 高:成员身份解析 |
| GET | `/api/v1/resolve` | 昵称、wxid、md5 解析为会话 | Read | `chat-service.resolveMd5` | 是 | 是 | 高;新资源宜返回稳定 ID 和歧义候选 |
| POST | `/api/v1/report` | 接收完整结构化日报并导出 HTML/PNG;当前拒绝外部 `templateRef` | Write:本地文件 | `group-report-service.exportGroupReport` | 是 | 是 | 低/中:低层渲染契约,Agent 须先拼完整结构 |
| POST | `/api/v1/agent/group-report` | 读群消息、调用 AI 生成群总结、导出 HTML/PNG | Execute:AI/本地文件 | `agent-group-report-service.generateAgentGroupReport` | 是 | 是 | 高但有模型费用/数据出站;结果不等同于报告历史记录 |
| GET | `/api/v1/agent/status` | Agent Hub、connector、数据 API、数据库状态 | Read | `agentHubService.getStatus` | 是 | 是 | 中:只覆盖 Agent Hub,不是应用总能力 |
| POST | `/api/v1/agent/send` | 通过 Agent Hub 连接器发送文字或媒体 | Execute:微信发送 | `agentHubService.testSend` → `WechatSendGateway`/iLink | 是 | 是 | 高但 R2;是测试入口,不含统一 Action policy/幂等确认 |
| GET | `/api/v1/wechat-personal/send-capability` | 个人微信 text/image/voice 能力状态 | Read | `PersonalWechatCapabilityService`,由 `ScheduledReportApiService` 包装 | 否(共享定义有,界面未展示) | 是 | 高但只代表 personal,不代表 iLink |
| GET | `/api/v1/scheduled-reports` | 列出旧 DTO 可表达的定时日报规则 | Read | `ScheduledReportApiService.list` → 规则投影 | 否(共享定义有,界面未展示) | 是 | 高但不完整:仅 `source_chat` 目标 |
| POST | `/api/v1/scheduled-reports` | 创建旧型定时日报,来源群即发送目标 | Write:规则配置 | `ScheduledReportApiService.create` → `AutomationRuleStore` | 否 | 是 | 高;功能受旧 DTO 限制 |
| GET | `/api/v1/scheduled-reports/{id}` | 单条旧型日报规则投影 | Read | `ScheduledReportApiService.get` → `AutomationRuleStore` | 否 | 是 | 中/高:只支持兼容投影规则 |
| PATCH | `/api/v1/scheduled-reports/{id}` | 修改旧型日报规则 | Write:规则配置 | `ScheduledReportApiService.update` → `AutomationRuleStore` | 否 | 是 | 高但只能改旧字段/目标 |
| DELETE | `/api/v1/scheduled-reports/{id}` | 删除旧型日报规则 | Write:删除配置 | `ScheduledReportApiService.delete` → `AutomationRuleStore` | 否 | 是 | 中;新 API 应标 R3 并防护系统规则 |
| POST | `/api/v1/scheduled-reports/{id}/enable` | 启用规则 | Write:配置/未来执行 | `ScheduledReportApiService.setEnabled` → `AutomationRuleStore` | 否 | 是 | 高;启用后未来可能发送微信 |
| POST | `/api/v1/scheduled-reports/{id}/disable` | 暂停规则 | Write:配置 | `ScheduledReportApiService.setEnabled` → `AutomationRuleStore` | 否 | 是 | 高 |
| POST | `/api/v1/scheduled-reports/{id}/run` | 手动触发完整日报规则 | Execute:可能调用 AI、保存历史、发微信 | `ScheduledReportService.runScheduledReportNow` → `AutomationService` | 否 | 是 | 高但 R2;当前无 HTTP 幂等键/确认 |
| GET | `/api/v1/scheduled-reports/{id}/executions` | 旧型 execution 历史和新 Automation execution 投影 | Read | `ScheduledReportService.listExecutions` | 否 | 是 | 高但旧响应模型/有限留存 |
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 旧文档称复用 PNG 重发 | Execute 路由存在但当前固定 `501 not_supported` | `ScheduledReportApiService.retrySend` | 否 | **路径有,语义已失效** | 无:不能重发 |
| GET | `/api/v1/media/{mediaId}` | 读取消息关联的图片二进制 | Read | `http-media-service.readImageMedia` | 否 | 是 | 高:图像证据 |
| HEAD | `/api/v1/media/{mediaId}` | 图片资源存在性/响应头 | Read | `http-media-service.readImageMedia` | 否 | 否 | 低/中 |
| GET | `/api/v1/query/capabilities` | Query Tool 支持的结构化操作、范围和上限 | Read | `LocalQueryApiService.capabilities` | 否 | 是(独立章节) | 高,但不是 TraceMemo 应用能力清单 |
| POST | `/api/v1/query/messages` | 单会话、范围、时间、方向、类型等确定性消息读取 | Read | `LocalQueryApiService.messages` | 否 | 是 | 高:推荐 Query primitive |
| POST | `/api/v1/query/search` | Knowledge 关键词检索,返回覆盖、新鲜度和 OCR 命中 | Read | `LocalQueryApiService.search` + `KnowledgeSearchService` | 否 | 是 | 高:必须读取 coverage/freshness |
| POST | `/api/v1/query/message-context` | 通过 opaque `messageRef` 读取前后文 | Read | `LocalQueryApiService.context` | 否 | 是 | 高:稳定消息引用 |
| POST | `/api/v1/query/conversation-overview` | 单会话范围的概览证据和 source coverage | Read | `LocalQueryApiService.overview` | 否 | 是 | 高:broad summary |
路由来源:[http-server.ts](../../src/main/http-server.ts#L212)、scheduled/query/media route factory(同文件 L404-L670)。总数是代码中声明的业务方法模板,不代表静态 handler 都正确拒绝其他动词:`/health`、`/current_time`、`/contact`、`/chatroom`、`/recent_chat`、`/chatlog`、`/group_snapshot`、`/resolve`、`/agent/status` 没有检查 `req.method`。这些路径携带错误动词仍会走同一 handler;特别是 `POST /health` 也绕过 Token 检查,因为鉴权例外按 pathname 判断。新/旧路由都应 fail closed 并对不支持的方法返回 405。
全局 `OPTIONS` 在路由和鉴权前处理;媒体额外支持 HEAD。非 health 路径要求 `Authorization: Bearer …`,但 `readBody` 没有大小上限。路由外层目前没有统一请求 schema、统一错误 envelope 或 request id。
## 3. Internal Capability Inventory
这里按产品能力追 Service → IPC/Renderer → HTTP → 外部 Agent,而不是按当前 API 名字扩展。
| 业务域 | 已有能力及实现 | IPC / Renderer | HTTP 现状 | Agent 结论 |
|---|---|---|---|---|
| Chat / Contact | 联系人、群聊、最近会话、解析、历史消息、群快照、消息周边上下文、媒体定位;`contact-resolution-service` 可精确匹配别名并返回歧义候选 | `db:getContacts`、`db:getGroupSnapshot`、消息查询/around 等,Chat/Contact/档案 UI | 旧 Reader routes + `/query/*` + 图片 `/media/{id}` | 已有 Read API 基础完整。新配置应使用 `m_nsUsrName` 对应的 wxid/roomId 等稳定 ID,不应把昵称当长期键 |
| Query / Knowledge | `messages`、`search`、`message-context`、`conversation-overview`;Knowledge 索引覆盖/新鲜度;Image OCR 可进入 Knowledge 搜索和证据 | `knowledge:search/getStatus/startIndex/cancelIndex`、AI Search UI、`LocalQueryToolExecutor` | 四个 Query 操作均已 HTTP 化;`query/capabilities` 只描述 Query Tool | 没有需要重做的基础 Query primitive。缺的是统一应用 capability/status、更多外层筛选和 API Center 展示 |
| Group Analytics | 活跃/沉默当前成员、每人 messageCount/lastMessageTime、窗口时间、memberCount、unattributed/system 消息、firstMessageTime、freshness/complete/limitations;索引未新鲜时最多等待 2 秒并如实降级 | `group-stats:getMemberStats`;聊天页群统计 UI | 无 | P1 候选。当前 query 要求群 `userMd5` + epoch 毫秒。former sender 只以限制文案给出数量,没有 formerMembers 数组/结构化计数;没有覆盖 former sender 的群总消息数字段,需扩 DTO 后再承诺 |
| Group Exit Monitor | enabled/running/nativeMonitor 状态、监控 roomId 集合、lastChecked、unread、事件历史;每次回传最多 500 条,但完整事件历史 append-only 长期保存;可立即 check、改范围、启停、筛事件、清历史、mark read | `group-exit-monitor:*`;`GroupExitMonitorWorkspace` | 无 | P0。拆成 monitor state/config、events、check。`checkNow` 可能发现事件并触发自动通知,不是纯读操作;重启监控会重建快照基线,暂停期成员变化不会补报 |
| Automation | 实际规则类型:`daily_report`、`scheduled_report`、`leave_notification`。`daily_report` 是现有消息触发条件/动作链,不是任意流程引擎;退群通知为固定 ID singleton。规则 CRUD、enable、执行记录读写均已有 Service/Store | `automation:*`;AutomationWorkspace 有规则、日志、定时执行、启停、删除和状态 UI | 没有通用 Automation API;仅 scheduled-report 兼容映射 | P0。使用 typed rule union;不要把内部任意 draft 原样开放。规则创建当前缺省 enabled=true,未知值会被归一化成默认;需 HTTP 严格校验,先 disabled + validate,再显式 enable |
| WeChat Send / Action | `WechatSendGateway` 有 personal/iLink transport resolution、text/image/voice/file 统一类型及 Send Log;`WechatActionGateway` 做 capability preflight、Automation purpose allowlist、Action audit、幂等和 Automation 3 秒间隔 | `wechat-personal:send`、`sendGeneratedTtsVoice`、`wechat-action-log:list`、Agent Hub connector 相关 IPC/UI | `/agent/send` 只走 Agent Hub/iLink 测试发送;个人 capability 有独立 GET;两类日志没有 HTTP | 分 transport 公布 capability;R2 send 通过经审计的业务门面,不直接暴露底层 gateway。现有 Action Gateway 还不是普适安全策略:`triggerType=user` 不按 purpose 限制;普通 `/agent/send` 没传 idempotency key,也没有 Action audit |
| Agent Hub | status、connector login/reconnect/disconnect、notification recipient/send、logs、conversation list/detail/clear、入站 inbox retry | `agent-hub:*`;Agent Hub UI | 仅 `/agent/status` 和 `/agent/send`;无 conversation/log HTTP | status 有只读价值。对话记录包含完整收发正文;inbox 包含 context token/raw items。Connector 生命周期、登录 QR/验证码、收件箱和通知 recipient 应保持 internal |
| Reports / Templates | 手动 report render/export;AI group report;本地 Report History list/save/update template/delete;内置/已安装模板和市场 catalog/install/uninstall | `report:*`、`report-template:*`、`report-template-market:*`;Reports 与 Template Market UI | `/report` 低层 export,`/agent/group-report` AI 生成;没有 history/template API | P1:只读 Report History 元数据/资产可分离设计。现有 `listGeneratedReports` 会读取每张 PNG 为 base64,并返回结构快照和本机绝对路径,不可原样直出。模板目录可读列入 P2;安装/卸载涉及网络与本地包写入,不宜第一批开放 |
| Recall Archive | 后台监听撤回变化,最多按会话存归档消息/撤回记录;chat-service 将 archive merge 到历史读结果 | 没有独立 CRUD IPC;设置开关和消息渲染 | 没有独立 archive API;旧 `/chatlog` 可能随底层消息返回 `recalled` 标记;Query DTO 未声明 recalled 字段 | 不开放原始 archive 管理。后续 Query 应明确返回 `recalled`/来源,避免把已撤回归档当普通消息证据 |
| OCR / Image Insight | System OCR 本地识别;image-text-index status/count/start/pause/resume/cancel/clear/repair;Image Insight 读/解密图片并可调用 AI Provider | `system-ocr:*`、`image-text-index:*`、`image:*` IPC;Search/Report UI | OCR 派生文本可通过 `/query/search` 得到;索引管理、单图 AI 分析无 HTTP | 已有搜索能力可用。状态可纳入 capability/status;索引删除、key/decoder 配置、任意图像 AI 分析涉及成本、私密图片和索引破坏,不列第一批 |
| 系统 / 数据 / 其他 | account discovery、DB key 管理、数据库 connect/root 重开、设置写入、cache summary/clear、app update、voice/TTS、export/import、Reader Skill 本地安装信息 | 多组 IPC;Settings、Cache、Export、Update、Voice UI | 无相应 HTTP API | 只读脱敏运行状态可按需求列 P2;DB key/root、通用 settings patch、cache 清理、任意文件路径、更新安装、TTS synthesis 等保持 internal |
### A. Chat / Contact 与 ID 语义
- `/contact`、`/chatroom` 改用 `listContactsAsync`,因为 macOS Session 可能只有原始 wxid/chatroom id,需要 hydrate 显示名;`ScheduledReportApiService` 却使用同步 `listContacts()` 解析群名。稳定 `talker` 可直接解析,但名称输入在需要 hydration 的运行时可能失败/退化。这是可复用 adapter 应统一异步解析的理由。
- ID 现在不是一个口径:旧 Query 的 `scope.conversationId`/`target` 解析为 `Contact.md5`;`group-stats` 传 `userMd5`;监控用 `roomId`(`xxx@chatroom`);新的 scheduled automation 用 `sourceConversationId`(wxid/roomId);老 HTTP 路由混用昵称、wxid、md5。保持已有 Reader 契约不动,新 API facade 应统一对外 canonical `conversationId`(底层当前联系人的稳定 username/wxid 或 roomId),并在 main adapter 转为服务所需 md5。名称只做 resolve,不持久化到规则。
- `chatlog` 时间边界是 Unix 秒,Query `absolute` 内部也是秒,而 group stats IPC 是 epoch 毫秒;新 Agent 资源建议用带时区 ISO-8601 输入/输出,并在 facade 单点转换。
- `/query/messages` 有 200 上限,`messageRef` 是 opaque 稳定引用;图片 OCR 文字和 Coverage 分开呈现。`/chatlog` 则支持旧 talker/time 风格但读取结果没有同等结构边界;作为兼容 Reader 保留,不作为新 Agent 配置/分析的默认接口。
### B. Group Exit Monitor 与 Leave Notification
`GroupExitMonitorService` 的真实 IPC 有 `getState`、`setEnabled`、`setGroups`、`checkNow`、`listEvents`、`clearEvents`、`markRead`。事件是群成员差异事实,包含 roomId、member wxid/name、previous/current count、detectedAt;monitor state 中 `events` 只是最近 500 条快照,`totalEventCount` 对应完整内存历史。
`AutomationService.handleGroupExit` 只处理 `BUILTIN_LEAVE_NOTIFICATION_RULE_ID` 对应的 singleton 规则。规则另有 `notifyScope` / `notifyRoomIds` 二次范围、target、template。Agent 配“监控 A/B/C”需改 monitor 范围;配置通知目标/通知哪些被监控群则另改这条 leave notification automation。两者不能合并为 `/monitors/{id}/notify`。
### C. Automation 与 Scheduled Report
`AutomationRuleStore` 的真实方法有 `listRules/getRule/createRule/updateRule/saveLeaveNotificationRule/deleteRule/setRuleEnabled`;`AutomationExecutionLogService` 提供 `list/record/clear/countSince`。执行日志最多留存 200 条,clear 属于破坏性操作。规则在 `{userData}/automation/rules.json` 中 JSON 持久化。
`scheduled-report-service.ts` 的调度来源是 `automationRuleStore.listRules()`,执行交给 `AutomationService.executeScheduledRule()`,execution 从 Automation Log 投影。迁移后的旧 `tasks.json`/`executions.json` 是只读历史存档。旧 HTTP API 通过 `ScheduledReportApiService` 转换旧 DTO;创建、修改、删除、启停最终也是读写 `AutomationRuleStore`。所以正确方案是保留兼容 facade,并建立 Automation canonical API,不要继续增加第二个 scheduled-report store。
旧 facade 的限制:只列/操作可投影为 `target.type === 'wechat_group'`、且目标等于来源群的规则。如今 scheduled automation 支持 source_chat/self/file_transfer/contact,故通过新 UI 创建为文件传输助手或联系人目标的规则,会从旧 `/scheduled-reports` 列表隐藏。旧 API 输入 schema 也不能表示完整 scheduled config(成员名、消息类型、模板、timeout、postfix 等)。
写 API 前还需处理 `AutomationRuleStore` 的归一化和持久化契约:未知 ruleType 会降成 `daily_report`,大部分错误枚举会静默落安全默认;缺省 enabled 是 true;`persist()` catch 写盘错误后只记 warning,Store 仍返回创建/更新后的对象。HTTP adapter 必须先 strict validate,且 Store 需要可观察的持久化结果,不能把内存态冒充成功。
### D. Group Analytics 确认项
`GroupStatsService.getMemberStats` 已有可直接复用的核心计算;接口具体有:
- 当前群成员:`memberCount`、`activeMembers`、`silentMembers`、各活跃成员 `messageCount`/`lastMessageTime`;
- 查询窗口:`startTime`、`endTime`(epoch ms)、`firstMessageTime`;
- 数据完整性:`freshness = fresh|stale|unknown`、`complete`、`limitations`;
- 诊断:`unattributedMessages`、`excludedSystemMessages`。
成员名单是当前成员集合;知识库统计的 sender 不在当前集合时被排除,只在 `limitations` 中增加“另有 N 位窗口内发言者已不在当前群成员名单”。Service 不返回其身份/每人消息数,也没有 `totalMessageCount`。若 Agent 需要“前成员榜”或全群消息数,需要先扩展 Service/shared type;不能由 API adapter 从 limitation 文案反解析。
## 4. API / Docs / API Center Drift
| 项目 | 代码事实 | 漂移/影响 |
|---|---|---|
| HTTP、共享定义与界面列表 | HTTP 有 30 个 method/path 模板;`LOCAL_API_ENDPOINTS` 定义 14 项,Renderer `API_ENDPOINTS` 实际展示 12 项 | 16 个 HTTP 操作模板没有共享定义;另有 2 个已定义项(个人微信能力、定时日报列表)没有展示。界面仅呈现 12/30 项,不能作为完整 API catalog |
| Scheduled Report 展示 | 共享定义只有 `GET /scheduled-reports`;该项本身也未进入 Renderer 列表 | POST 和 task action 不显示,GET 列表也不显示;Agent 在 API Center 里无法发现这组 API |
| WeChat Capability 展示 | 共享定义有 `GET /wechat-personal/send-capability`;Renderer 列表未包含它 | API Center 看不到个人微信发送能力状态,用户可能误把 Agent Hub 状态当成完整发送能力 |
| Query 展示 | Query 文档在 `api.md` 的独立 LLM-friendly 章节,Service/HTTP 实现完整 | API Center 看不到;用户可能误认为 Reader API 仍只有旧 chatlog |
| Media 方法 | `/media/{mediaId}` 支持 GET、HEAD | 文档仅列 GET;API Center 都未列 |
| Retry Send | 文档表称 retry-send“复用已有 PNG 重试发送” | `ScheduledReportApiService.retrySend()` 当前无条件抛 `501 not_supported`;integration/unit tests 也未覆盖 retry 路由的这项现状 |
| Scheduled Report 完整性 | 旧 facade 只 project `source_chat` | UI 可保存的其他 scheduled target 会从旧 API list/get 隐藏;不是两份存储,但旧 API 不是 Automation API 的完整别名 |
| Health 版本 | `/health` 固定返回 `version: "1.0.0"` | 与当前 package version `2.5.0` 不同,Agent 无法据此判断应用版本 |
| HTTP 动词 | 九个静态 GET 语义路由无 method guard | POST/PATCH/DELETE 等也可能调用读取逻辑;`/health` 任意 method 均免 Token。测试目前未锁定统一 405 契约 |
| 请求/错误 schema | JSON parsing 和错误形状分散:通用 `sendError`、ScheduledReport 专用 error、Query status body、业务自身 result | Agent 要写多套解析逻辑;共享 API schema 和统一错误 code 不存在 |
| 命名 | `/contact`、`/chatroom`、`/recent_chat`、`/group_snapshot` 与 `/scheduled-reports`、`/query/*`、`/agent/*`、`/wechat-personal/*` 并存 | snake_case 旧路径、资源路径和“Agent 为业务 owner”的命名混杂;新接口不能继续沿用此漂移 |
文档 [api.md](../agent/api.md#L35) 基本列出当前 HTTP 路径,Query 在后续单独说明;除 HEAD 外没有发现漏写的当前业务路径,但 retry-send 的成功语义过期。API Center 的来源是单独的 [local-api-test.ts](../../src/shared/local-api-test.ts) 和 [apiEndpoints.ts](../../src/renderer/src/features/api-center/model/apiEndpoints.ts),没有从 HTTP route/schema 派生。测试现有 `local-api-auth` 覆盖鉴权、媒体、部分 Query 和 Agent send;`scheduled-report-api` 覆盖旧生命周期;`local-api-contact-search` 覆盖 hydrate。它们没有自动比对 HTTP route、文档、Catalog 三者,也没有覆盖全部 method guard 和 retry-send。
## 5. Candidate API Matrix
风险按本任务口径:R0 只读;R1 本地配置/应用状态修改;R2 微信发送、AI/provider 调用等外部副作用;R3 删除或清空不可轻易恢复的数据。R1 不代表没有后续行为:enable 一条定时规则会武装未来的 R2 执行。
| Capability | 当前实现 | 当前 API | 建议 | Agent 用例 | Risk | Priority |
|---|---|---|---|---|---|---|
| 联系人/群/会话 resolve | Chat Service + Contact Resolution | 有旧 routes;Query 内 resolve | 保留旧路由;新 resource 返回稳定 ID、歧义候选 | 查找群并取得 roomId | R0 | P0(复用) |
| 结构化消息/搜索/上下文/概览 | LocalQueryApiService + Knowledge | `/query/*` | 保持契约;加 route schema/catalog,后续可升级稳定 ID | 查聊天、关键词/OCR、补上下文 | R0 | P0(复用) |
| 应用 capability discovery | 各 Service 能回答局部状态 | 无;`query/capabilities` 仅 Query Tools | 新 `GET /capabilities`,区分 supported/available/reason/operations | 发现自动化、监控、统计、发送 transport | R0 | P0 |
| Group Exit Monitor 状态/范围 | GroupExitMonitorService | 仅 IPC | GET state + PATCH enabled/roomIds | 查看监控、监控/停止一个群 | R0/R1 | P0 |
| Group Exit events | Monitor JSONL + listEvents | 仅 IPC | GET 带 stable roomId/time/cursor/limit | 最近 7 天谁退群 | R0 | P0 |
| 手动检查退群 | checkNow 会扫描并触发事件 handler | 仅 IPC | 有外部通知时按 R2 操作开放,先 validate effects + confirm | 立即检查一次 | R2 | P1 |
| Automation 规则 CRUD | AutomationRuleStore | 通用 IPC;HTTP 仅旧 scheduled facade | typed union CRUD;create disabled;validate 再 enable;保护 singleton/system rules | 创建、列出、修改、暂停自动化 | R1/R3(delete) | P0 |
| Automation validation/dry-run | 现有编辑器 preview 分散;无通用 validator API | 无 | `POST /automations/validate`;不落盘、不发送 | 确认群、目标、模板、下次运行和能力 | R0 | P0 |
| Automation execution history | AutomationExecutionLogService,最多 200 条 | schedule 专属旧投影 | 规范化 Automation execution 读接口;清日志不开放第一批 | 查看失败、按 rule 过滤 | R0/R3(clear) | P0 |
| Group member stats | GroupStatsService | 仅 IPC | 按稳定 group ID + ISO window 读统计;先补 former/total 语义 | 近 30 天活跃榜 | R0 | P1 |
| WeChat capability | personal capability service;Agent Hub status | personal GET + Agent status | 新全局 capability 含 personal/iLink 和内容能力;旧路由保留 | 检查发送当前是否可用 | R0 | P0 |
| 手动微信发送 | WechatSendGateway + Action Gateway | `/agent/send` iLink test send | 新 send command 经受限 Action facade,强制 stable recipient、confirm、idempotency | 文件助手测试消息 | R2 | P1 |
| Send Log / Action audit | Send Log 500 条;Action audit 500 条;IPC action-log | 无 HTTP | 分层只读分页,preview 脱敏;按 executionId/requestId 关联 | 查最近发送失败、审计规则动作 | R0 | P1 |
| Agent Hub status | AgentHubService.getStatus | `/agent/status` + IPC | 保留 alias,新资源名归 `/agent-hub/status`,与 app capabilities 分开 | 查 Hub/connector online | R0 | P1(复用) |
| Agent Hub 对话内容 | Conversation Store,最多 50 会话×500 条 | 仅 IPC | 默认为 Internal;若产品确认需要,另做显式 opt-in、分页/时间过滤 | 查看机器人与某人的对话 | R0(高隐私) | 不建议第一批 |
| AI 群日报 | AgentGroupReportService + export | `/agent/group-report` | 保留兼容;未来先 validate model/range/group/data egress,再异步 job | 生成临时总结图片 | R2(provider/本地文件) | P1 |
| 日报历史 | ReportHistory Service,IPC CRUD | 无 | 分页 metadata DTO;图片 asset 单独下载;不返回 base64/路径/完整 snapshot | 昨天生成过哪些日报 | R0 | P1 |
| 模板列表 | Template Service + market catalog | 仅 IPC | 仅已安装模板只读列表列 P2 | 有哪些日报模板 | R0 | P2 |
| 模板安装/删除/历史改版 | Template Service/Market + Report History | 仅 IPC | 不开放通用 HTML/路径写入;将来单独授权且保留校验 | 安装或修改模板 | R1/R3 | Maybe/P2 |
| Recall archive 查询 | 内部 archive merge 到历史消息 | 无独立 API | 不单独开放磁盘 Archive;给 Query 增 `recalled` 来源标记 | 找被撤回消息 | R0(敏感/语义风险) | P2 |
| Image OCR index 操作 | image-text-index service | IPC(含 clear/repair) | coverage 状态可汇入 capabilities/status;不让 Agent 操作 clear/reset | 查 OCR 覆盖 | R0/R1/R3 | P2 |
| AI image insight | ImageInsightService 读/解密图片并调 vision provider | IPC | 不暴露任意 hash/message AI 分析,除非有成本/隐私授权 | 理解群图片 | R2 | 不建议第一批 |
| DB key、根目录、settings、cache | 多个设置/DB/cache Service | IPC/UI | 禁止通用 settings patch / 文件路径 / DB key API;只加白名单状态字段 | 修改本机数据库、安全设置 | R1/R3 | 不建议开放 |
| Connector 生命周期/inbox | AgentHubService + WechatInboundInbox | 仅 IPC/内部 | connector 登录、验证码、QR、inbox、context token 不对 Agent 暴露 | 重连或直接拿入站 token | R1/R2 | 不建议开放 |
## 6. P0 Recommendation
第一批目标是“让 Agent 能配置和核验 TraceMemo,但不意外发消息”。建议只包括:
1. `GET /api/v1/capabilities`:应用级 capability,不与现有 `/query/capabilities` 合并。返回版本、DB/readiness、supported vs available、不可用原因和依赖;发送分 personal/iLink 与 text/image/voice 能力。
2. Group Exit Monitor:读取状态、显式配置 monitored roomIds、读取历史事件。`PATCH` 只接 canonical 群 ID,拒绝不存在/非群 ID;修改范围响应明确显示后台 baseline/check 状态。`check` 先列 P1,因为它可能启动退群通知发送。
3. Automation typed CRUD:列/读规则、创建 disabled 规则、更新、显式启停、执行历史读取。Leave Notification 仍使用固定 singleton id,不允许创建重复规则;拒绝未知字段/未知 enum,而不是靠 `normalizeRuleDraft` 静默修正。
4. `POST /automations/validate`:验证目标群、稳定通知 recipient、模板变量、report/template 配置、send capability 和 nextRunAt;只返回 plan,不落盘、不发送。
5. 运行状态与错误契约:一致的 405、最大 body、请求 ID、错误 envelope;这是任何新 Agent 写接口前的 foundation,不是大规模权限系统。
Agent 实现示例(概念流程):
- “监控 A/B/C 退群”:resolve 三个群为 roomId → validate scope → PATCH monitor group IDs。
- “A 群有人退出就通知文件助手”:读取 monitor scope 和 singleton leave rule → validate 类型/notify scope/target → 更新规则但保持 disabled → 用户/Agent 明确 enable。监控与 leave-notification 是两份正交配置。
- “每天 20:00 生成产品群日报”:resolve sourceConversationId → validate scheduled rule(包含 target/transport/模板/时区/next run)→ 创建 disabled → 显式 enable。旧 `/scheduled-reports` 无法表达所有当前 config,不承担新 Agent CRUD。
- “昨天哪些自动化失败”:读 automation executions,按本机 timezone/UTC offset 和 status 查询,不清理日志。
## 7. Proposed Resource Model
采用业务 capability 资源,HTTP server 只负责 transport、auth、body、统一错误;每个 domain route 调用独立的 main-process API facade/Service adapter。Facade 复用当前 Service/Store,不把 UI IPC 当 HTTP RPC 转发层。
| Resource | 职责 | 现有路径的处理 |
|---|---|---|
| `system` | health、版本、应用级 capabilities、运行状态 | `/health` 保留;新增 `/capabilities`;`query/capabilities` 不改语义 |
| `contacts` / `groups` | 稳定 ID 列表、resolve、群成员快照/统计 | `/contact`、`/chatroom`、`/resolve`、`/group_snapshot` 保留兼容 |
| `query` | 消息/搜索/上下文/概览证据 | 现有 `/query/*` 保持;query ID 口径升级需兼容 reader skill |
| `monitors` | 退群监控范围、启停、事件、显式 check | 新 `/monitors/group-exits`,与通知规则分离 |
| `automations` | 规则 typed CRUD、validate、启停和运行 | 新 `/automations` 是 canonical HTTP resource;底层仍由 `AutomationRuleStore` 存储 |
| `executions` | 跨 Automation/Action/Send 的只读运行视图 | 新 `/executions` read model,不合并各自写存储 |
| `wechat` | transport capabilities、受控 send command、Send Log/Action audit | `/agent/send` 与 `/wechat-personal/send-capability` 保留兼容 |
| `reports` | report history 元数据/asset;installed templates read-only | `/report` 与 `/agent/group-report` 保留为不同兼容操作 |
| `agent-hub` | Hub/connector 状态;conversation API 默认 internal | `/agent/status` 保留 alias,不复用 app capability |
| `developer` | 高级 raw request tester 与诊断 | API Center 的 tester;不作为 Agent 业务 API |
共享资源原则:新 API 输入以 stable id 为主、名字只用于 resolve;所有写请求 strict validate;时间对新资源用 offset ISO-8601;分页使用 `limit` + `cursor`;成功/失败使用一个 typed envelope;不直接返回 app userData path、token、context token、AES key 或原始 transport payload。
## 8. Safety Model
### 当前边界
- 默认监听 `127.0.0.1:6131`,但 host/port 由设置和 `api:start` 调用传入,API Center 会警告非 loopback。CORS 只允许 loopback Origin,但不带 Origin 的 curl/Agent 请求仍可用 Token;CORS 不是本地进程授权边界。
- `/health` 公开,其余 endpoint 共用单一 Bearer Token。Token 为 32 random bytes、safeStorage 加密存储并设 `0600`,没有 read/config/send scope。拿到 token 即可读取聊天,也能建/删/启停规则、立即发送。
- HTTP `/agent/send` 的消息进入 `WechatSendGateway`,因此有低层 Send Log;但没有 `WechatActionGateway` 的业务 Action audit/策略决策,也没有调用方 Idempotency-Key。Personal capability 路由只报告个人微信状态。
- `WechatActionGateway` 的请求包含 `purpose`、`triggerType`、recipient、content、`idempotencyKey`、`executionId`;Automation trigger 有 purpose allowlist,sender capability 会先检查,审计记录会保存 content preview/hash。当前 `evaluateWechatActionPolicy` 对 `triggerType=user` 不做 purpose allowlist,`shouldUseAiPolicy` 固定 false;幂等只对显式 key 或历史特定 scheduled request 生效。它目前只发个人微信,不能直接替换 iLink send。
- Action Audit 和 Send Log 各自上限 500;Automation Execution Log 上限 200。三类日志粒度不同,不是重复的同一事实。
### 分级建议
| 风险 | 操作 | 建议控制 |
|---|---|---|
| R0 | 查询聊天/成员/事件/状态/统计/日志/报告元数据 | 保留本地 Token;响应明确 scope、coverage、freshness、隐私字段 |
| R1 | 修改监控群、Automation 配置、启停、模板设置 | typed validation、dry-run plan、显示持久化成功;enable 需确认它武装未来发送 |
| R2 | 微信发送、立即执行日报/退群通知 check、调用 AI Provider 生成报告 | 明确 recipient 和内容/规则;`Idempotency-Key` 必填;统一 Action policy + Action audit + Send Log;重复请求回放原结果;返回发送状态 |
| R3 | 清理退群事件、删除 report、清空执行/发送/Action 日志、清 cache/index、删规则 | 初始不开放;如以后开放,独立权限、预览计数、可恢复备份/本地确认,不接受批量 wildcard |
现有 Bearer Token 不足以支撑“查询、配置、发送、清理”都对一个不受信 Agent 开放。近期不必造完整账户系统,但应先修路由 method/body/validation,提供默认只读或 disabled 配置工作流;后续可加入多个 named token + scope(`read`, `configure`, `send`, `destructive`),R2 请求确认和 durable idempotency。风险分类也需承认本地 artifact write(如 `/report` 导出)不是配置本身,可先按 R1 local-write 处理。
## 9. Compatibility Plan
1. 不 rename/remove 任何现有 route。Reader Skill 依赖旧 contacts/chatlog/media 路径;新 structured query 已经是更合适的 Agent query,但两者并行。
2. 新 `/automations` 读写同一 `AutomationRuleStore`。旧 `/scheduled-reports*` 改为明确标注 Deprecated 的 compatibility adapter;维持现有 request/response shape 和 source_chat 子集,不维护第二个任务存储。响应可加 `Deprecation` header/文档说明,未定 sunset 前不返回 breaking error。
3. `/scheduled-reports` 的投影不能假装覆盖所有 scheduled automation。旧 list/get 只反映 source_chat;新 clients 必须迁到 `/automations?type=scheduled_report`。旧 `retry-send` 保留返回 501,文档明确废弃;不能伪造已发送成功。
4. `/agent/send` 继续表示现有 iLink/Agent Hub 测试发送。新 `/wechat/send` 必须先明确 transport/recipient schema,再通过能统一 personal+iLink 的受控 Action facade;如果不能保留旧发送语义,就将旧路径作为 adapter 而不是简单 alias。
5. `/wechat-personal/send-capability` 保持 personal-only 兼容 view;新全局 capability 返回 transport map。应用能力 `/capabilities` 和 Query Tool 的 `/query/capabilities` 各自有清晰不同的契约。
6. 同一 shared contract/catalog 应供 HTTP 验证、API Center、文档和 route contract tests 使用;把实际路由、文档和 API Center 三者 drift 变成测试失败,而不是发布后人工发现。
## 10. Proposed Phase Plan
### Phase A — HTTP Contract / Facade
给现有 routes 加明确 method guard、body size limit、严格 shared request schema、统一错误 envelope/request id;补 `AutomationRuleStore` 写盘成功/失败结果;建立稳定 conversation ID adapter 和 route contract tests。保留 raw Node HTTP,不需要为第一阶段换 web framework。
### Phase B — Read + Validation P0
增加应用 `/capabilities`、monitor state/events、automation rules/executions 读取、`/automations/validate`、group stats adapter。monitor `check` 因可能触发 notification 暂留 P1 或先加 side-effect confirmation。把新资源路径、schema、风险元数据接进 EndpointCatalog/生成文档。
### Phase C — Automation Configuration
开放 disabled create、PATCH、启停和 DELETE 防护;退群通知专用 singleton upsert 接入同一规则资源;scheduled report 使用真实完整 config;旧 scheduled API 只做 compatibility projection。先做 dry-run 再允许 enable。
### Phase D — Side Effects / Execution Read Model
扩展一层统一 `Action` facade 支持 iLink + personal transports,并有 per-purpose policy、recipient allowlist/validation、确认语义、强幂等、Action audit 到 Send Log correlation。再开放 send/check/run;按需增加 `/executions` read projection,不合并底层日志存储。
### Phase E — API Center
Overview、Query、Monitors、Automations、WeChat Actions、Reports、Developer 分区;按业务任务做 schema-aware 表单/效果预览,Raw Request Tester 留在 Developer。展示 Token scope/host 范围/transport capability,而不只是固定 URL 测试器。
## 11. Concrete Endpoint Proposal
下表是下一阶段建议契约,不表示当前已实现。新写 API 应统一错误:`{"error":{"code":"...","message":"...","details":{...}},"requestId":"..."}`。时间使用带 offset 的 ISO-8601;接口只接受 stable IDs。
| Method | Path | Request → Response | Risk | Underlying Service |
|---|---|---|---|---|
| GET | `/api/v1/capabilities` | 无 → app version/readiness + `query`,`groups.memberStats`,`groupExitMonitor`,`automations`、每种 `wechat.transport/content` 的 `supported/available/reason` | R0 | 新薄 facade 汇总 LocalQuery、GroupStats、Monitor、AutomationStore、personal capability、AgentHub status |
| GET | `/api/v1/monitors/group-exits` | 无 → `{enabled,running,monitoredConversationIds,lastCheckedAt,eventCount}` | R0 | `GroupExitMonitorService.getState` |
| PATCH | `/api/v1/monitors/group-exits` | `{enabled?,monitoredConversationIds?}` → 保存后的 state;监控 ID 必须 resolve 到现有群 | R1 | `setEnabled` / `setMonitoredRoomIds` |
| GET | `/api/v1/monitors/group-exits/events?conversationId=&since=&until=&limit=&cursor=` | ISO 时间和稳定群 ID → `{events,nextCursor}`,事件显式 `eventId`、group/member、counts、detectedAt | R0 | `GroupExitMonitorService.listEvents`;为无 cursor 的现有 list 加稳定分页 adapter |
| POST | `/api/v1/monitors/group-exits/check` | `{confirmSideEffects:true}` + `Idempotency-Key` → checkedAt、新事件数、notification execution refs | R2 | `checkNow`;因检查可发现事件并调用 leave-notification Automation,不应标成纯读 |
| GET | `/api/v1/automations?type=&enabled=` | 无 → typed rules page;包括 singleton leave rule | R0 | `AutomationRuleStore.listRules` |
| POST | `/api/v1/automations` | typed `AutomationRuleDraft`,create 默认 `enabled:false` → `{rule}` | R1 | `AutomationRuleStore.createRule`(需先强化 strict validation/persist result) |
| GET | `/api/v1/automations/{ruleId}` | 无 → `{rule}` | R0 | `AutomationRuleStore.getRule` |
| PATCH | `/api/v1/automations/{ruleId}` | typed partial config → `{rule}`;`ruleType` 不可变 | R1 | `AutomationRuleStore.updateRule` |
| DELETE | `/api/v1/automations/{ruleId}` | 无 → `{deletedId}`;默认拒绝 builtin/system singleton 删除 | R3 | `AutomationRuleStore.deleteRule` + protected-id policy |
| POST | `/api/v1/automations/validate` | typed draft → `{valid,normalizedDraft,issues,effects,resolvedTargets,nextRunAt,capabilities,validationId}`;不保存、不发送 | R0 | 新 validator adapter:contact resolve、template validator、capability services、schedule pure functions |
| POST | `/api/v1/automations/{ruleId}/enable` | `{validationId,confirmFutureEffects:true}` → `{rule}`;validation hash 必须匹配当前规则 | R1(武装未来 R2) | `AutomationRuleStore.setRuleEnabled` + validator |
| POST | `/api/v1/automations/{ruleId}/disable` | 无 → `{rule}` | R1 | `AutomationRuleStore.setRuleEnabled` |
| POST | `/api/v1/automations/{ruleId}/run` | `{confirmSideEffects:true}` + `Idempotency-Key` → `{execution}` | R2 | `AutomationService.executeScheduledRule`;只对具有 `run` 语义的 rule type 开放 |
| GET | `/api/v1/automations/executions?ruleId=&status=&trigger=&since=&until=&limit=&cursor=` | 无 → `{executions,nextCursor}` | R0 | `AutomationExecutionLogService.list`;需加 timestamp/filter/cursor adapter |
| GET | `/api/v1/groups/{conversationId}/member-stats?start=&end=` | ISO 时间窗口 → active/silent/counts/freshness/complete/limitations;未来要 former members 则先扩 shared result | R0 | `GroupStatsService.getMemberStats`;adapter 把 canonical roomId 转 md5 |
| GET | `/api/v1/wechat/capabilities` | 无 → personal/iLink 分 transport 状态与内容能力 | R0 | `PersonalWechatCapabilityService` + `WechatSendGateway.hasIlinkSender`/Agent Hub connector health |
| POST | `/api/v1/wechat/send` | `{recipient:{type,id},content:{type:"text",text},confirm:true}` + 必填 `Idempotency-Key` → `{actionId,status,transport,sendLogRef}` | R2 | 扩展后的 `WechatActionGateway`/统一 Action facade;不得裸调 `WechatSendGateway.send` |
| GET | `/api/v1/wechat/send-logs?status=&since=&limit=&cursor=` | 无 → 只读、脱敏 Send Log page | R0 | `WechatSendGateway.listSendLog` / `WechatSendLogService.list` |
| GET | `/api/v1/wechat/action-logs?executionId=&status=&since=&limit=&cursor=` | 无 → 业务 Action 审计 page;preview 默认截短/可省略 | R0 | `WechatActionLogService.list` / `WechatActionGateway.listAuditRecords` |
| GET | `/api/v1/executions?source=&status=&since=&until=&limit=&cursor=` | 无 → 统一只读 projection,每项保留 source/executionId/trigger/action/status/error/times/correlation IDs | R0 | 组合 `AutomationExecutionLogService`、Action audit、Send Log;不合并其存储 |
| GET | `/api/v1/reports/history?groupId=&since=&until=&limit=&cursor=` | 无 → 仅元数据分页,不带 `generatedImage`、本地绝对路径或完整 report snapshot | R0 | `listGeneratedReports` 经 summary adapter,最好先让 Service 支持 metadata-only |
| GET | `/api/v1/reports/history/{reportId}/image` | 无 → PNG binary | R0 | Report History 的 file resolver;仅按内部 report id 解析,不接收任意路径 |
| GET | `/api/v1/report-templates` | 无 → 已安装模板的 stable id/name/version/available 列表 | R0 | `reportTemplateService.list` |
| GET | `/api/v1/agent-hub/status` | 无 → hub/connector/dataApi/dbReady 的脱敏状态 | R0 | `AgentHubService.getStatus`;`/api/v1/agent/status` 保留 alias |
`POST /wechat/send` 建议先只开放 text + 明确目标;image path/url、voice 和 file 类型各自扩大本机文件/外传风险,需独立 validation/permission,不从现有任意 `msg` 输入自动继承。
## 12. Do Not Expose
- 任意数据库 key、图片 AES key、微信登录凭据、iLink `context_token`/bot token、inbound inbox raw items;这些是密钥或未处理消息,不是业务 API。
- 通用 `settings:set`、任意 `dbRoot`、数据库 disconnect/reopen、cache 全清、Knowledge/OCR 索引 clear/reset;配置或 destructive blast radius 远高于 Agent automation 管理需求。
- Agent Hub QR/login/verify-code/reconnect/disconnect/connector lifecycle。它会影响进程状态/账户登录,也会暴露登录材料。
- `WechatInboundInbox` pending/clear/complete/recordFailure 等队列控制。它是 at-least-once 消息交付内部机制,外部 ack 会破坏不丢消息语义。
- Agent Hub 完整对话正文默认不暴露。若明确产品需要,独立设计用户授权、最小时间窗口、分页、清理和脱敏;现有 Conversation Store 为本机完整收发留档。
- 任意本地 file path、path traversal 类导出/报告资产操作;不要把 IPC 的 file chooser、reveal、delete path 形状直接变 HTTP 参数。
- 单图 AI insight/任意 Vision analyze、批量 TTS synthesis、AI Provider 配置/测试和 app update download/install;它们具有费用、敏感图片/文本上传或应用安装影响。
- 清除退群、Automation、发送/Action、报告历史或批量发送接口作为 P0。清理类不是“管理配置”的必要前提,应使用 R3 独立权限及本地可恢复流程。
- 通用“任意 Automation DAG/任意脚本/任意 purpose”的创建。当前实现只有明确的三类规则和固定动作,不是 workflow engine;扩展功能应有显式 ruleType/schema,而不是暴露内存对象。
## 13. Open Questions
以下属于产品边界选择,无法只从代码决定:
1. 新 Agent API 是否允许同时操作个人微信和 Agent Hub/iLink,还是第一版限定一个 transport?现有 capability 与 send endpoint 分属两套连接状态。
2. Agent 是否默认只拿只读 scope;配置和 R2 send 是否要求独立 token/用户确认?当前只有单一 Bearer Token。
3. 多账号是否属于本阶段?当前 Query/GroupExit 绑定当前活动数据库账号,Agent Hub 有自己的 connector accountId;没有统一 account-scoped API model。
4. Agent Hub 完整对话是否要作为 API capability?它含完整消息正文,和从微信数据库按 query 搜历史是不同隐私边界。
真实微信 runtime 可用性、平台 hydration 和 transport 能力需在后续实现集成测试中验证;静态代码审计无法替代真实数据库/微信连接的运行时验证。
@@ -6,7 +6,6 @@
执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用:
- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`;
- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`;
- 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`。
@@ -22,18 +21,6 @@ WCDB_DEBUG_LOGS=1 pnpm dev
开启后会输出 `GETMSG-xxx` 请求耗时和 native `WCDB-EXPLAIN` 执行计划,不记录聊天正文。取消该环境变量或设为 `0` 即可关闭。
## Go 命令找不到
如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行:
```bash
go version
```
命令不可用表示当前终端的 `PATH` 没有找到 Go。Windows 默认安装位置是 `C:\Program Files\Go\bin`。确认 Go 已安装并把该目录加入系统 `PATH` 后,关闭并重新打开终端或 IDE,再重新执行 `go version` 和 `pnpm dev`。
如果 Go 刚完成安装,已经打开的终端不会自动继承新的环境变量;重开终端是必要步骤。不要绕过连接器构建直接启动 `electron-vite dev`,否则 Agent Hub 的微信连接器不会生成。
## Electron 二进制缺失或下载失败
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
@@ -68,4 +55,4 @@ Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时
## 仍无法启动时
保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
保留首次错误的完整输出,并同时记录操作系统、Node.js 与 pnpm 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
+16 -7
View File
@@ -6,7 +6,6 @@
- Electron + React + TypeScript;
- pnpm 7+;
- Go(构建微信连接器);
- 平台对应的 Electron/native 构建环境。
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
@@ -18,7 +17,7 @@ pnpm install
pnpm dev
```
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
本地依赖安装与 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
常用检查:
@@ -30,29 +29,39 @@ pnpm test:integration
pnpm test:e2e:build
```
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、微信连接器、构建和 Playwright 测试;需要对应平台环境。
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、构建和 Playwright 测试;需要对应平台环境。
## 代码变更对应文档
| 代码区域 | 需要同步检查的文档 |
| --------------------------------------------------------- | ------------------------------------------------------- |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
| `src/main/services/recall-archive-service.ts` | `user-guide/privacy.md` |
| `src/main/services/system-ocr-service.ts`、`image-text-index-service.ts` | `user-guide/knowledge.md`、`concepts/how-it-works.md`、`user-guide/privacy.md` |
| `src/main/services/image-insight-service.ts`、AI Provider | `user-guide/report.md`、`user-guide/privacy.md` |
| `src/shared/automation.ts`、自动化服务与执行网关 | `user-guide/report.md`、`concepts/how-it-works.md`、`docs/README.md` |
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
两条容易被写错的边界:
- **防撤回已下线**(`src/main/services/recall-archive-service.ts` 保留但不再启动):设置入口隐藏,`recallProtectionEnabled` 在所有读写路径上被强制收敛为 `false`。不要把它写回用户指南。
- **图片文字索引(本机 OCR)与图片理解(需要 Provider)是两条不同的路径**:前者写入本地索引、能被搜索,且不联网;后者只在日报和设置里的模型检测中使用。改其中一条时不要把另一条的隐私口径带过去。
## 文档检查
提交文档变更前至少执行:
```bash
git diff --check
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
# 过时版本号、旧品牌名、旧结构叙述、MCP 误解
rg -n "v2\.1\.7|2\.4\.0|v2\.2\.0 兼容期|无鉴权|mcpServers" README.md docs --glob '*.md' --glob '!development/overview.md'
# 不存在的产品结构(定时日报已并入自动化)
rg -n "日报 → 定时日报|Monitor / Automation" README.md docs --glob '*.md'
```
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。发版前额外确认 `README.md` 里的版本号与 `package.json` 的 `version` 一致。
+171
View File
@@ -0,0 +1,171 @@
# 界面开发规范:按钮与主题色
这份规范回答一件事:**为什么同一个产品里,有的按钮是主题色,有的还是浏览器默认的黑白方角。**
先看一个真实案例 —— 同一屏里的两组按钮:
```
主界面:「更新图片文字索引」 ← 主题色(正确)
弹窗里:「取消」「开始索引」 ← 浏览器默认样式(错误)
```
两者渲染出来完全不同,用户会以为是两个不同的产品。根因不是"设计没定颜色",
而是**组件在导出时把样式丢了**。下面写清楚怎么避免。
---
## 1. 永远不要写裸 `<button>`
任何可点的按钮都必须来自 `components/ui/button`:
```tsx
import { Button } from '../ui'
<Button variant="outline" onClick={handleCancel}>取消</Button>
```
**唯一的例外**:结构性控件(导航项、Tab、列表行、图标热区)——它们有自己
成套的布局样式,用原生 `<button>` 是合理的,但**必须**带 `className`,
且样式写在对应的 `.scss` 里,不要在 JSX 里临时拼颜色。
```tsx
// 可以:结构性控件,样式来自 .scss
<button type="button" role="tab" className={active ? 'active' : ''} onClick={...}>
今日日报
</button>
```
**判据**:如果这个按钮在别的界面也会以同样形态出现("取消"、"保存"、"删除"),
它就该是 `Button`;如果它只在某一个位置有意义(侧栏导航项),才考虑原生。
---
## 2. 三种角色,只有三个默认变体
`Button` 提供 6 个变体,但**日常只用其中 3 个**:
| 角色 | `variant` | 长什么样 | 用在哪 |
| --- | --- | --- | --- |
| 主要 | `default` | 主题色实底 | 这一步用户唯一该做的事 |
| 次要 | `outline` / `ghost` | 描边 / 无底色 | 取消、返回、并列的辅助操作 |
| 危险 | `destructive` | 红色实底 | 删除、清空、不可恢复的操作 |
另外两个(`secondary` / `link`)按需用;`link` 只用于正文里的行内跳转。
**一条硬约束:同一个界面(或同一个弹窗)里,`default` 最多出现一次。**
两个主题色实底按钮并排,等于没有主次。
---
## 3. 弹窗按钮:组件已经带样式了,不要再包一层
`AlertDialogCancel` 和 `AlertDialogAction` **自带**按钮样式(分别是 `outline`
和 `default`),直接写文字即可:
```tsx
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleStart}>开始索引</AlertDialogAction>
</AlertDialogFooter>
```
**不要**再套一层 `Button`:
```tsx
// 反面写法:外层已经有样式了,再包一层只会产生重复类名
<AlertDialogCancel asChild>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
```
需要危险动作时,用 `className` 覆盖(`cn` 走 tailwind-merge,同族类后者生效):
```tsx
<AlertDialogAction className="bg-destructive text-destructive-foreground">
删除
</AlertDialogAction>
```
---
## 4. 颜色只能用语义 token,禁止硬编码
颜色全部走 Tailwind 的语义类,它们背后是 `--tm-*` 变量,换主题时自动跟随:
```
背景 bg-primary / bg-surface / bg-accent / bg-destructive
文字 text-foreground / text-primary-foreground / text-muted-foreground
描边 border-border / border-border-subtle / border-disabled-border
```
```tsx
// 对
<Button className="bg-primary text-primary-foreground">保存</Button>
// 错 —— 换主题时这行不会跟着变
<Button className="bg-[#247a63] text-white">保存</Button>
```
**判据**:JSX 里出现 `#` 开头的颜色、`rgb(...)`、或 Tailwind 的调色板名
(`bg-green-600`、`text-slate-500`)—— 都是漏用 semantic token 的信号。
---
## 5. 「默认样式」的三个常见来源
排查界面里冒出来的黑白方角按钮时,按这个顺序找:
**① 组件导出时把样式丢了。** 最常见。把 Radix 的 primitive 原样导出:
```tsx
// 错:渲染出来就是浏览器默认按钮
const AlertDialogCancel = AlertDialogPrimitive.Cancel
```
正确做法是 `forwardRef` 包一层,挂上 `buttonVariants`:
```tsx
const AlertDialogCancel = React.forwardRef<...>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
))
```
**判据**:`components/ui/` 里凡是导出 Radix primitive 的地方,都要确认它是
"样式化的封装"还是"原样透传"。原样透传只对布局容器(`Root` / `Portal` /
`Group`)成立,对**可点元素**(`Close` / `Action` / `Cancel` / `Item`)不成立。
**② `asChild` 里重复包了一层。** 外层已经带样式、子元素又带一次,虽然因为
同族类后生效而不会出错,但会产生冗余类名。**能去掉一层就去掉。**
**③ 原生 `<button>` 忘写 `className`。** 见第 1 节的例外条款 —— 结构性控件也必须
有样式来源。
---
## 6. 提交前检查清单
- [ ] 新增的可点元素来自 `Button`,不是裸 `<button>`
- [ ] 同一界面里 `default` 变体不超过一个
- [ ] 危险操作走 `destructive`,不是红色硬编码
- [ ] 弹窗按钮没有重复包 `Button`
- [ ] JSX 里没有 `#` 开头的颜色、没有 Tailwind 调色板名
- [ ] `components/ui/` 里新导出的可点 primitive 已经挂上 `buttonVariants`
- [ ] 组件测试覆盖到按钮的可见性与点击行为(testid 用 `xxx-yyy` 连字符命名)
---
## 7. 一个反面案例的复盘
弹窗里的「取消 / 开始索引」显示成浏览器默认样式,原因就是第 5 节第 ① 条:
`alert-dialog.tsx` 把 `Cancel` / `Action` 两个 primitive 原样导出了。
修复是给它们各加一个 `forwardRef` 封装,挂上 `buttonVariants`。**组件本身没坏**,
所有调用方一行不用改,样式自动生效 —— 这正是把样式收在 `components/ui/` 里的价值:
**修一处,全产品对齐。**
如果你发现某个地方的按钮"没跟上主题",先别去改那个界面 ——
**先看它用的组件是不是漏了样式。**
@@ -0,0 +1,92 @@
# 微信系统消息(sysmsg)解析与格式兼容
微信的「系统消息」(入群、撤回、成员变动等)以 XML(`<sysmsg>`)存放在消息内容里,
但**同一类提示的 XML 结构会随客户端版本变化**。本文说明 TraceMemo 的解析方式,
以及在遇到新格式时应当怎么扩展。
## 两类格式
### 旧格式:正文直接放在 `<plain>`
```xml
<sysmsg type="delchatroommember">
<delchatroommember>
<plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></plain>
<text><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></text>
<link>
<scene>qrcode</scene>
<text><![CDATA[撤销]]></text>
</link>
</delchatroommember>
</sysmsg>
```
解析:命中 `delchatroommember`,直接取 `<plain>`。
### 新格式:正文在 `<template>`,用 `$名称$` 引用 link
```xml
<sysmsg type="sysmsgtemplate">
<sysmsgtemplate>
<content_template type="tmpl_type_profilewithrevokeqrcode">
<plain><![CDATA[]]></plain>
<template><![CDATA["$adder$"通过扫描你分享的二维码加入群聊 $revoke$]]></template>
<link_list>
<link name="adder" type="link_profile">
<memberlist><member>
<username><![CDATA[wxid_xxxxxxxx]]></username>
<nickname><![CDATA[成员昵称]]></nickname>
</member></memberlist>
</link>
<link name="revoke" type="link_revoke_qrcode" hidden="1">
<title><![CDATA[撤销]]></title>
</link>
</link_list>
</content_template>
</sysmsgtemplate>
</sysmsg>
```
三个要点:
- `<plain>` 变成**空 CDATA**,正文挪进 `<template>`;
- 正文里的 `$名称$` 是占位符,按 `<link_list>` 中 `link[name]` 回填;
- `hidden="1"` 的 link 在微信里是**可点击按钮**,纯文本展示时应省略其文案。
## 解析流程
`src/main/message-parser.ts` 的 `parseSystemMessage()` 按以下顺序尝试:
| 顺序 | 分支 | 处理对象 |
| --- | --- | --- |
| 1 | `extractRecallMessage` | `<revokemsg>` 撤回通知 |
| 2 | `extractSysmsgTemplateText` | `<sysmsgtemplate>` 模板消息 |
| 3 | `extractDelChatroomMemberText` | `<delchatroommember>` 成员变动 |
| 4 | 通用提取(`plain` → `text` → `title`),再退回 `fallbackSystemText` | 其余未覆盖类型 |
第 4 步之前会先调用 `stripSysmsgLinkList()` 剥掉 `<link_list>`。
## 为什么必须显式处理新格式
通用提取链只在第 1~3 步全部落空时才执行,而新格式恰好让它落空:
`<plain>` 是空 CDATA,又没有 `<text>`,于是取到 `<title>` ——
那是 `hidden="1"` 按钮的标题。**结果是整条系统消息只剩一个按钮文案**,
例如把「某某通过扫描你分享的二维码加入群聊」显示成「撤销」。
因此三处约束缺一不可:
1. 模板分支必须排在通用提取之前;
2. 占位符回填必须尊重 `hidden="1"`;
3. 通用提取前先剥 `<link_list>`,作为未知类型的防护。
## 新增一类系统消息时
1. 从真实消息中取出 `content`(`<sysmsg>` 原文),确认 `type` 与承载正文的标签;
2. 在 `parseSystemMessage()` 里加一个**早于通用提取**的分支;
3. 补 `tests/unit/message-parser.test.ts` 用例,**新旧两版各一条**,防止回归;
4. 文档与代码注释只写结构,不粘贴真实会话内容、昵称、wxid 或二维码链接。
## 相关位置
- 解析实现:`src/main/message-parser.ts`
- 单元测试:`tests/unit/message-parser.test.ts`
+60 -3
View File
@@ -1,5 +1,62 @@
# macOS 数据访问说明(兼容入口)
# macOS 关闭 SIP 教程
完整内容已移到[macOS 数据访问与系统权限](./platform/macos.md)。
SIP(System Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
保留此文件是为了兼容应用内已经发布的帮助链接。请不要把“关闭 SIP”当作默认安装步骤;只有当当前连接页面明确要求时才处理,并在完成后恢复系统安全设置。
> 只在连接页面明确提示需要关闭 SIP 时才处理。首次连接失败时,先确认微信版本、账号目录和登录时机,再按本文操作。关闭 SIP 不是 TraceMemo 的常规安装步骤,也不应长期保持关闭。
## 准备
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
- 需要进入 macOS 恢复模式。
- 请先保存正在编辑的文件,并预留一次重启时间。
## 关闭 SIP
### Intel Mac
1. 关机。
2. 按下开机键后,立刻按住 `Command + R`。
3. 保持按住,直到进入 macOS 恢复模式。
### Apple Silicon Mac(M1/M2/M3/M4/M5)
1. 关机。
2. 长按开机键不放。
3. 直到出现启动选项界面后松开。
4. 选择"选项",进入 macOS 恢复模式。
### 在恢复模式中执行命令
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**。
2. 选择 **Terminal(终端)**。
3. 在终端中输入:
```bash
csrutil disable
```
4. 按回车执行。
5. 看到关闭成功提示后,重启电脑。
## 确认是否生效
重启回到正常桌面后,打开"终端",执行:
```bash
csrutil status
```
看到 `System Integrity Protection status: disabled.` 才算关闭成功。
若仍显示 `enabled`,说明没有生效。常见原因是没在恢复模式里执行,或系统刚做过大版本更新——
macOS 大版本更新会把 SIP 重置回开启状态,此前关过也会失效,需要重新按上面的步骤操作。
## 重新开启 SIP
拿到数据库密钥后,建议重新进入恢复模式,在终端中执行:
```bash
csrutil enable
```
然后重启电脑,恢复系统安全设置。
+1 -1
View File
@@ -8,7 +8,7 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
2. 确认微信数据目录指向当前账号。
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
3. 只在页面明确要求时处理系统授权或 SIP;关闭 SIP 的具体步骤见[关闭 SIP 教程](../mac-disable-sip.md),按页面提示完成密钥获取后,恢复你平时使用的安全设置。
4. 返回应用重新检测账号、数据库和图片资源状态。
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
+27 -5
View File
@@ -1,6 +1,6 @@
---
name: tracememo-reader
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体,并管理定时日报任务。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结、查询或修改定时日报时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
---
# TraceMemo Reader
@@ -28,7 +28,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
## 端点速查
| 方法 | 路径 | 用途 |
| ------ | ----------------------------------- | ------------------------------------------------- |
| ------ | ------------------------------------------------------- | ------------------------------------------------- |
| GET | `/health` | 健康和数据库状态 |
| GET | `/current_time` | 本机时间与时区 |
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
@@ -38,7 +38,12 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
| POST | `/query/messages` | 按目标与时间范围取消息(结构化,不调用 AI) |
| POST | `/query/search` | 受限语义关键词检索(依赖本地索引,见 freshness) |
| POST | `/query/message-context` | 用 `messageRef` 取某条消息的前后文 |
| POST | `/query/conversation-overview` | 按会话与时间范围提取可总结的证据 |
| GET | `/query/capabilities` | Query 端点能力目录 |
| GET | `/wechat-personal/send-capability` | 个人微信发送能力状态(文字 / 图片 / 语音) |
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
| POST | `/scheduled-reports` | 创建定时日报任务 |
@@ -48,10 +53,13 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
| POST | `/scheduled-reports/executions/:executionId/retry-send` | 复用已有 PNG 重试发送 |
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
| POST | `/agent/send` | 已连接机器人发送测试 |
| POST | `/agent/send` | 已连接机器人发送测试(文字或本地图片) |
这个 API **不只是只读的**:`/report` 会渲染并写文件,`/agent/send` 会真的发出微信消息,`/scheduled-reports*` 会创建、修改、删除或立刻执行定时任务。这些调用都要先确认用户意图;`DELETE` 与 `/agent/send` 尤其需要用户明确确认。
## 定时日报管理
@@ -78,7 +86,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
}
```
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态不是 `ready` 时(`unsupported`、`unconfigured`、`needs_binding`、`initializing` 或 `error`),直接说明需要先在 TraceMemo 的“设置 → 发送能力”里完成个人微信绑定和能力检测,不要继续创建任务。
### 查看、修改和执行
@@ -102,6 +110,20 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- 根据多条消息整理出的总结;
- 没有来源支持的推断。
## 结构化查询(query/\*)
需要按目标 + 时间范围稳定取数时,优先使用 `query/*`,而不是自己拼 `chatlog`:
- `query/messages`:按 `target`、`timeRange`、`direction`、`messageTypes` 取消息;
- `query/search`:受限语义关键词检索,依赖本地索引;
- `query/message-context`:用返回的 `messageRef` 取前后文;
- `query/conversation-overview`:按会话与时间范围提取可总结的证据。
两个要点:
- `messageRef` 是服务端生成的不透明引用,**不要**自行构造 wxid、md5 或数据库路径;
- `query/search` 依赖异步建立的本地索引。`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说“这段范围暂时无法确认”,**不能**下“没有找到”的结论。
## 媒体消息
当 `/chatlog` 返回图片消息时:
-674
View File
@@ -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
View File
@@ -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`。
+7 -3
View File
@@ -33,7 +33,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
- 你需要有权访问要读取的微信账号和聊天数据。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。「图片文字索引」不在此列——它在本机识别图片里的文字,不需要配置 AI。
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
@@ -134,6 +134,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
- [生成群聊日报或总结](./report.md)
- [转写微信语音](./voice.md)
- [导出聊天档案](./export.md)
- [让日报、退群通知按规则自动运行](../README.md#日报与自动化)
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
- [让外部 Agent 查询微信历史](../agent/overview.md)
@@ -159,14 +160,17 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
## 8. 需要配置 AI 吗?
不一定。浏览聊天、普通关键词搜索、建立本地知识库和导出不要求在线 AI 服务。
不一定。浏览聊天、普通关键词搜索、建立本地知识库、图片文字识别、离线语音转写和导出都不要求在线 AI 服务。
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
这两种情况容易混淆:**本机识别图片里的文字**(图片文字索引)不联网、不需要 AI 服务;**让模型看图并回答**(图片理解)才需要配置 AI 服务。
## 9. 数据和隐私的最低须知
- 微信数据库、聊天解析和本地索引默认留在本机。
- 微信数据库、聊天解析、本地索引和图片文字识别默认留在本机。
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
- 图片文字索引只在本机识别,原始图片不会因为本地识别而上传;识别出的文字会进入本地索引,供搜索和“问问微信”使用。
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
+16 -2
View File
@@ -25,7 +25,7 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
### 状态怎么读
| 状态 | 含义 |
| ---- | ---- |
| ------------------- | ------------------------------------------------ |
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
@@ -38,6 +38,21 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
## 图片文字索引(另一份索引)
本地索引其实有两份,彼此独立:
- **聊天记录索引**(也就是上面说的 Knowledge):索引文字消息,用于跨会话、跨时间查找;
- **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图等),把识别结果也变成可搜索的文字。
“独立”的意思是:聊天记录索引建好了,并不代表图片里的文字就搜得到。建立图片文字索引后,可以在“问问微信”里直接搜截图或公告图里写过的词。
图片文字索引只在本机识别,原始图片不会因为本地识别而上传。它**不等于“图片理解”**:识别文字不联网、不需要 AI 服务;而让模型看图并回答属于图片理解,需要配置 AI 服务,走的是另一条路径。
识别失败的图片可以单独重试,也有“只重建搜索索引、不重新识别图片”的修复入口——修索引不需要重跑几万张图。
两个索引都可以在“设置 → 本地索引”里集中查看状态、建立、同步和清理。
## 账号隔离
每个微信账号使用独立的本地索引。切换账号时,应用不会把一个账号的索引混入另一个账号的搜索结果。
@@ -58,4 +73,3 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
## 产品术语(可选)
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
+10 -2
View File
@@ -9,6 +9,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
- 读取和解析微信数据库;
- 聊天档案浏览和普通关键词搜索;
- 本地 Knowledge 索引及其账号隔离;
- 图片文字索引:识别图片中的文字在本机完成,原始图片不会因为本地识别而上传;
- 离线语音转写;
- 导出文件生成和本地日报历史。
@@ -16,7 +17,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
## 什么时候会请求外部服务
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。图片文字索引、离线语音转写、档案浏览和普通搜索不会触发这一步。当前设置页给出的边界是:
- 当前用户问题;
- 受控检索所需的有限上下文;
@@ -28,7 +29,14 @@ Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的
## 语音和媒体
离线语音转写在本机进行。图片理解属于 AI 功能:只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。无法读取的媒体不会被自动“猜出来”。
离线语音转写在本机进行。
图片有两条完全不同的路径,不要混为一谈:
- **图片文字索引**:在本机识别图片里的文字,产出的是本地索引数据;原始图片不会因为这一步被上传,也不需要配置 AI 服务。
- **图片理解**:属于 AI 功能。只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。
无法读取的媒体不会被自动“猜出来”。
## Local HTTP API
+6 -2
View File
@@ -33,9 +33,11 @@
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
## 定时日报
## 定时日报(在自动化里)
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
定时日报现在是「自动化」里的一种规则,不再单独占一个页面:打开一级导航的「自动化」,新建或编辑一条「定时日报」规则,选择群聊、执行时间、日报范围、消息类型和发送目标。日报页顶部的指引条也会直接跳到自动化。
TraceMemo 会按计划执行:
```text
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
@@ -45,6 +47,8 @@
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
发送目标当前支持**当前群聊、文件传输助手、自己、指定好友**——还不是任意群发。
## 让报告更可靠
- 先选正确的群和时间范围;
+16
View File
@@ -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
+2 -1
View File
@@ -19,6 +19,8 @@ asarUnpack:
- node_modules/silk-wasm/**
- node_modules/sherpa-onnx-node/**
- node_modules/sherpa-onnx-*/**
- node_modules/@napi-rs/system-ocr/**
- node_modules/@napi-rs/system-ocr-*/**
extraResources:
# Keep the updater provider in every packaged Windows app. electron-builder also
# regenerates this file during publish, using the same release configuration.
@@ -33,7 +35,6 @@ extraResources:
- mobile_daily_report.html
- mobile_daily_report_v1.html
- mobile_daily_report_v2.html
- connectors/wechat/win32-x64/**
- key/win32/x64/**
- runtime/win32/**
- wcdb/win32/x64/**
+6 -1
View File
@@ -19,6 +19,11 @@ asarUnpack:
- node_modules/silk-wasm/**
- node_modules/sherpa-onnx-node/**
- node_modules/sherpa-onnx-*/**
# System OCR(@napi-rs/system-ocr)的 native binding 必须 unpacked,否则
# macOS 的系统 OCR 会在运行时 MODULE_NOT_FOUND。平台本机的 binding 由
# scripts/after-pack.cjs 校验,外架构的同级包在 afterPack 里被裁掉。
- node_modules/@napi-rs/system-ocr/**
- node_modules/@napi-rs/system-ocr-*/**
extraResources:
# Keep the updater provider in every packaged macOS app. electron-builder also
# regenerates this file during publish, using the same release configuration.
@@ -29,7 +34,7 @@ extraResources:
to: resources
filter:
- '**/*'
- '!connectors/wechat-personal/**'
- '!runtime/darwin-arm64/**'
- from: docs/skill/tracememo-reader
to: skill/tracememo-reader
filter:
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
output: {
entryFileNames: '[name].js'
},
external: ['koffi', 'sherpa-onnx-node']
external: ['koffi', 'sherpa-onnx-node', '@napi-rs/system-ocr']
}
}
},
+19 -16
View File
@@ -1,6 +1,6 @@
{
"name": "tracememo",
"version": "2.4.0",
"version": "2.5.0",
"packageManager": "pnpm@7.33.7",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
"keywords": [
@@ -25,12 +25,13 @@
},
"main": "./out/main/index.js",
"scripts": {
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test",
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:e2e:build && playwright test",
"format": "prettier --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "npm run typecheck:node && npm run typecheck:web && npm run typecheck:test",
"typecheck:test": "node scripts/typecheck-tests.cjs",
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
"cp:env": "node scripts/ensure-env.cjs",
"prepare:env": "node scripts/ensure-env.cjs",
@@ -38,14 +39,13 @@
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
"prepare:wechat-native": "node scripts/prepare-wechat-native-runtime.cjs",
"start": "electron-vite preview",
"predev": "node scripts/ensure-electron-binary.cjs",
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
"dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm dev",
"poc:query-agent": "electron-vite build && node scripts/run-query-agent-poc.cjs",
"poc:query-agent:run": "node scripts/run-query-agent-poc.cjs",
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:component": "vitest run --config vitest.component.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
@@ -56,21 +56,18 @@
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64",
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64",
"build:native-services": "npm run build:wechat-connector",
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs",
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --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:send-runtime": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && cross-env TM_SEND_RUNTIME_BUILD=1 electron-builder --config electron-builder.send-runtime.yml --mac --arm64",
"build:mac:x64": "npm run typecheck && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"release": "npm run release:mac && npm run release:win",
"release:mac": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64,x64 && 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",
"release:mac:arm64": "npm run prepare:ffmpeg:mac:arm64 && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
"release:mac:x64": "npm run prepare:ffmpeg:mac:x64 && electron-builder --config electron-builder.yml --mac --x64 --publish always",
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
"release:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
"release:beta": "cross-env EP_PRE_RELEASE=true npm run release",
"release:stable": "npm run release",
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
@@ -78,7 +75,11 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-darwin-x64": "3.1.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@napi-rs/system-ocr": "1.2.0",
"@napi-rs/system-ocr-darwin-x64": "1.2.0",
"@napi-rs/system-ocr-win32-x64-msvc": "1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
@@ -109,7 +110,9 @@
"parse5": "^8.0.0",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"sherpa-onnx-darwin-x64": "1.13.3",
"sherpa-onnx-node": "1.13.3",
"sherpa-onnx-win-x64": "1.13.4",
"silk-wasm": "^3.7.1",
"tailwind-merge": "^3.6.0",
"unzipper": "^0.12.0",
+52 -3
View File
@@ -11,7 +11,11 @@ specifiers:
'@electron-toolkit/preload': ^3.0.2
'@electron-toolkit/tsconfig': ^2.0.0
'@electron-toolkit/utils': ^4.0.0
'@koromix/koffi-darwin-x64': 3.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-darwin-x64': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@playwright/test': ^1.62.1
'@radix-ui/react-alert-dialog': ^1.1.23
'@radix-ui/react-checkbox': ^1.3.11
@@ -70,7 +74,9 @@ specifiers:
react: ^19.2.1
react-dom: ^19.2.1
sass: ^1.102.0
sherpa-onnx-darwin-x64: 1.13.3
sherpa-onnx-node: 1.13.3
sherpa-onnx-win-x64: 1.13.4
silk-wasm: ^3.7.1
tailwind-merge: ^3.6.0
tailwindcss: 3.4.17
@@ -85,7 +91,11 @@ specifiers:
dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-darwin-x64': 3.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-darwin-x64': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
'@radix-ui/react-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
@@ -116,7 +126,9 @@ dependencies:
parse5: 8.0.1
pinyin-pro: 3.29.3
qrcode: 1.5.4
sherpa-onnx-darwin-x64: 1.13.3
sherpa-onnx-node: 1.13.3
sherpa-onnx-win-x64: 1.13.4
silk-wasm: 3.7.1
tailwind-merge: 3.6.0
unzipper: 0.12.5
@@ -1581,7 +1593,6 @@ packages:
cpu: [x64]
os: [darwin]
dev: false
optional: true
/@koromix/koffi-freebsd-arm64/3.1.0:
resolution: {integrity: sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==}
@@ -1685,6 +1696,46 @@ packages:
- supports-color
dev: true
/@napi-rs/system-ocr-darwin-arm64/1.2.0:
resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
dev: false
optional: true
/@napi-rs/system-ocr-darwin-x64/1.2.0:
resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
dev: false
/@napi-rs/system-ocr-win32-arm64-msvc/1.2.0:
resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
dev: false
optional: true
/@napi-rs/system-ocr-win32-x64-msvc/1.2.0:
resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
dev: false
/@napi-rs/system-ocr/1.2.0:
resolution: {integrity: sha512-r0f2xNH6U+sth44qF+lUP+2WuHSGUBAry5KSCNuaLDGRbgslFqeROr/qJJ/fb6AjBp3Ov+CJP5MdrOWoaoM3cw==}
engines: {node: '>= 10'}
optionalDependencies:
'@napi-rs/system-ocr-darwin-arm64': 1.2.0
'@napi-rs/system-ocr-darwin-x64': 1.2.0
'@napi-rs/system-ocr-win32-arm64-msvc': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
dev: false
/@nodelib/fs.scandir/2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -7547,7 +7598,6 @@ packages:
cpu: [x64]
os: [darwin]
dev: false
optional: true
/sherpa-onnx-linux-arm64/1.13.4:
resolution: {integrity: sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw==}
@@ -7586,7 +7636,6 @@ packages:
cpu: [x64]
os: [win32]
dev: false
optional: true
/side-channel-list/1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

+17 -9
View File
@@ -68,21 +68,29 @@
.overview {
margin-top: 2px;
}
/*
* hero 头像簇的几何必须由 contract 变量驱动,不能再写死容器尺寸。
*
* 生产导出会额外注入 `report-template-fragment-contract.ts`,其中
* `img.tm-avatar.tm-avatar--hero` 用 !important 把头像钉在
* clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)。
* 旧版这里写死 58x58(单头像 28x28),两个权威打架:头像实际 40px,
* 2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子,于是头像向右向下溢出容器,
* 视觉上越过卡片内边距、压到卡片边缘之外。
*
* 现在容器尺寸由内容决定(列宽/行高都取同一个变量):头像数 1..4 都不会溢出,
* 主题调整 --tm-avatar-hero-size 时容器与头像也不会分叉。
*/
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, var(--tm-avatar-hero-size, 40px));
grid-auto-rows: var(--tm-avatar-hero-size, 40px);
gap: 3px;
flex: 0 0 auto;
}
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
.avatar-grid.avatar-count-1 {
width: 28px;
height: 28px;
grid-template-columns: 1fr;
}
.avatar-grid.avatar-count-2 {
height: 28px;
grid-template-columns: var(--tm-avatar-hero-size, 40px);
}
.avatar-grid.empty-section {
display: none;
+17 -9
View File
@@ -61,21 +61,29 @@
font-size: 13px;
line-height: 1.55;
}
/*
* hero 头像簇的几何必须由 contract 变量驱动,不能再写死容器尺寸。
*
* 生产导出会额外注入 `report-template-fragment-contract.ts`,其中
* `img.tm-avatar.tm-avatar--hero` 用 !important 把头像钉在
* clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)。
* 旧版这里写死 58x58(单头像 28x28),两个权威打架:头像实际 40px,
* 2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子,于是头像向右向下溢出容器,
* 视觉上越过卡片内边距、压到卡片边缘之外。
*
* 现在容器尺寸由内容决定(列宽/行高都取同一个变量):头像数 1..4 都不会溢出,
* 主题调整 --tm-avatar-hero-size 时容器与头像也不会分叉。
*/
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, var(--tm-avatar-hero-size, 40px));
grid-auto-rows: var(--tm-avatar-hero-size, 40px);
gap: 3px;
flex: 0 0 auto;
}
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
.avatar-grid.avatar-count-1 {
width: 28px;
height: 28px;
grid-template-columns: 1fr;
}
.avatar-grid.avatar-count-2 {
height: 28px;
grid-template-columns: var(--tm-avatar-hero-size, 40px);
}
.avatar-grid.empty-section {
display: none;
Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

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

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
f1bbd88584075c0e6d3487357a06424949de26d7e6dcad803d210aab83b0cbda xkey_helper_4_1_13
a35d3fa67387e049cc30bc073f9e65b077aa1db9c5c2b183125dec695fa013b9 xkey_helper_4_1_13
+327 -23
View File
@@ -16,6 +16,15 @@ const REQUIRED_RUNTIME_PACKAGES = [
'koffi'
]
// electron-builder 26 skips macOS signing entirely when no Developer ID
// identity is configured, so an unpacked bundle can ship without a usable
// signature. macOS kills a helper whose code or signature is missing or
// modified even when SIP is disabled, which is what customers hit on newer
// macOS releases. Ad-hoc re-sign the runtime helpers and the outer bundle so
// every Mach-O verifies strictly; spctl still rejects ad-hoc code, which is
// acceptable for the SIP-disabled customer workflow.
const MACOS_HELPER_NAMES = ['xkey_helper', 'xkey_helper_4_1_13']
function getRuntimeResources(context) {
const productName = context.packager.appInfo.productFilename
return context.electronPlatformName === 'darwin'
@@ -78,11 +87,236 @@ function validateSherpaRuntime(runtimeResources, platform, arch) {
}
}
/**
* System OCR 用 native package(@napi-rs/system-ocr)。它是 external + asarUnpack,
* 打包后必须以 unpacked 形式存在,否则运行时会 MODULE_NOT_FOUND / native binding missing。
* Windows 与 macOS 都是 supported target,都要做硬校验(Linux 不是)。
*/
function systemOcrTarget(platform, arch) {
return platform === 'win32' ? `${platform}-${arch}-msvc` : `${platform}-${arch}`
}
function validateSystemOcrRuntime(runtimeResources, platform, arch) {
if (platform !== 'win32' && platform !== 'darwin') return
const target = systemOcrTarget(platform, arch)
const basePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
'@napi-rs',
'system-ocr'
)
const nativePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
'@napi-rs',
`system-ocr-${target}`
)
const requiredFiles = [
path.join(basePath, 'package.json'),
path.join(basePath, 'index.js'),
path.join(nativePath, 'package.json'),
path.join(nativePath, `system-ocr.${target}.node`)
]
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath))
if (missingFiles.length > 0) {
throw new Error(`Missing unpacked System OCR runtime: ${missingFiles.join(', ')}`)
}
}
/**
* koffi 运行期按 `${process.platform}-${process.arch}` 拼出原生包目录名
* (node_modules/koffi/src/koffi/index.cjs:153/175),找不到就直接抛
* "Cannot find the native Koffi module; did you bundle it correctly?"。
* pnpm 7 不支持 supportedArchitectures,会静默跳过外平台可选依赖,所以每个目标平台的
* koffi 原生包都必须在 package.json 里显式声明;这里再兜一层,缺了就让构建失败,
* 而不是发出一个装得上、却打不开 WCDB 的包。
*/
function koffiNativeTarget(platform, arch) {
if (platform === 'win32') {
return arch === 'x64'
? { label: 'Windows', segments: ['@koromix', 'koffi-win32-x64', 'win32_x64', 'koffi.node'] }
: null
}
if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) {
return {
label: 'macOS',
segments: ['@koromix', `koffi-darwin-${arch}`, `darwin_${arch}`, 'koffi.node']
}
}
return null
}
function validateKoffiRuntime(runtimeResources, platform, arch) {
const target = koffiNativeTarget(platform, arch)
if (!target) return
const nativePath = path.join(
runtimeResources,
'app.asar.unpacked',
'node_modules',
...target.segments
)
if (!existsSync(nativePath)) {
throw new Error(`Missing ${target.label} Koffi native module: ${nativePath}`)
}
}
function normalizeBuilderArch(arch) {
if (typeof arch === 'string') return arch
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
}
function runCodesign(args) {
try {
execFileSync('/usr/bin/codesign', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
const stderr =
error && typeof error === 'object' && 'stderr' in error ? String(error.stderr) : ''
if (stderr.trim() && error instanceof Error) {
error.message += `\n${stderr.trim()}`
}
throw error
}
}
function isMacosCodeValid(targetPath, run = runCodesign) {
try {
run(['--verify', '--strict', targetPath])
return true
} catch {
return false
}
}
function findMacosHelperPaths(runtimeResources) {
return MACOS_HELPER_NAMES.map((name) => path.join(runtimeResources, 'resources', name)).filter(
(helperPath) => existsSync(helperPath)
)
}
function signMacosHelpers(runtimeResources, run = runCodesign) {
const helperPaths = findMacosHelperPaths(runtimeResources)
for (const helperPath of helperPaths) {
chmodSync(helperPath, 0o755)
if (!isMacosCodeValid(helperPath, run)) {
run(['--force', '--sign', '-', helperPath])
}
for (const arch of ['arm64', 'x86_64']) {
try {
run(['--verify', '--strict', '--arch', arch, helperPath])
} catch (error) {
throw new Error(
'macOS helper signature verification failed: ' +
path.basename(helperPath) +
' (' +
arch +
')',
{ cause: error }
)
}
}
}
return helperPaths
}
/**
* codesign 只把这些位置当作「嵌套代码」并要求它们先各自签好,才肯签外层 app。
* 只遍历这一组根目录,而不是整个 bundle:Contents/Resources 下的
* app.asar.unpacked 里成千上万个原生文件不属于嵌套代码,逐个签既慢又无意义。
*/
const MACOS_CODE_LOCATIONS = [
'Frameworks',
'MacOS',
'PlugIns',
'XPCServices',
'Helpers',
'Library/LoginItems'
]
function collectNestedMacosCode(dir, depth, targets) {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name)
// framework 里的 Mantle -> Versions/Current/Mantle 这类符号链接指向真实文件,
// 真实文件会在更深的层级被走到;这里跳过以免重复签名。
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
if (/\.(app|framework|xpc)$/.test(entry.name)) {
targets.push({ path: entryPath, depth, bundle: true })
}
collectNestedMacosCode(entryPath, depth + 1, targets)
continue
}
if (!entry.isFile()) continue
if (readBinaryArchitectures(entryPath).length === 0) continue
targets.push({ path: entryPath, depth, bundle: false })
}
}
/**
* 返回嵌套代码的签名顺序:深度大的先签(framework 内部的 dylib、无扩展名的
* crashpad handler 先于 framework 本身,helper 的可执行文件先于 helper app),
* 同深度时文件先于 bundle。
*/
function findNestedMacosCodePaths(appBundlePath) {
const targets = []
for (const location of MACOS_CODE_LOCATIONS) {
const root = path.join(appBundlePath, 'Contents', ...location.split('/'))
if (existsSync(root)) collectNestedMacosCode(root, 1, targets)
}
return targets
.map((target, index) => ({ ...target, index }))
.sort((a, b) => {
if (a.depth !== b.depth) return b.depth - a.depth
if (a.bundle !== b.bundle) return a.bundle ? 1 : -1
return a.index - b.index
})
.map((target) => target.path)
}
/**
* Electron 43.1.0 的 darwin-x64 官方 zip(sha256 与上游 SHASUMS256.txt 一致)
* 里所有嵌套 Mach-O 都是未签名状态,darwin-arm64 那份则是 linker-signed。
* codesign 签外层 bundle 时要求子组件已签,否则直接报
* "code object is not signed at all" + "In subcomponent: ...",
* 所以 x64 出包时只签外层必然失败,必须先由内向外补签一遍。
*
* 这里不采用 `--deep`(Apple 已标记 deprecated):它会把外层的签名选项套用到
* 所有子组件上,将来接上 Developer ID + entitlements 时会把 app 的 entitlements
* 一并套到 helper 上,属于已知的坑。
*/
function signMacosAppBundle(appBundlePath, run = runCodesign) {
if (isMacosCodeValid(appBundlePath, run)) return appBundlePath
for (const nestedPath of findNestedMacosCodePaths(appBundlePath)) {
try {
run(['--force', '--sign', '-', nestedPath])
} catch (error) {
throw new Error(
'macOS nested code signing failed: ' + path.relative(appBundlePath, nestedPath),
{ cause: error }
)
}
}
run(['--force', '--sign', '-', appBundlePath])
try {
run(['--verify', '--strict', appBundlePath])
} catch (error) {
throw new Error('macOS app bundle signature verification failed: ' + appBundlePath, {
cause: error
})
}
return appBundlePath
}
/**
* A foreign-architecture binary only fails once the user touches the feature
* that needs it, so verify the ones whose filename is shared across
@@ -152,16 +386,24 @@ function validateReaderSkillRuntime(runtimeResources) {
* The loaders pick their package from process.platform/arch, so the siblings
* are dead weight — drop them.
*/
// 每个条目返回 platform package 的**完整后缀**(不含 package 前缀与连字符)。
const NATIVE_RUNTIME_PACKAGES = [
{
modules: [],
prefix: 'sherpa-onnx',
platformName: (platform) => (platform === 'win32' ? 'win' : platform)
platformName: (platform, arch) => `${platform === 'win32' ? 'win' : platform}-${arch}`
},
{
modules: ['@koromix'],
prefix: 'koffi',
platformName: (platform) => platform
platformName: (platform, arch) => `${platform}-${arch}`
},
{
// @napi-rs 的 platform package 目录名带 -msvc 后缀(win32-x64-msvc)。
modules: ['@napi-rs'],
prefix: 'system-ocr',
platformName: (platform, arch) => systemOcrTarget(platform, arch),
foreignPattern: /^system-ocr-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)(-msvc)?$/
}
]
@@ -173,12 +415,16 @@ function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) {
for (const runtime of NATIVE_RUNTIME_PACKAGES) {
const modulesRoot = path.join(unpackedRoot, ...runtime.modules)
if (!existsSync(modulesRoot)) continue
const expected = `${runtime.prefix}-${runtime.platformName(platform)}-${arch}`
const foreign = new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
const expected = `${runtime.prefix}-${runtime.platformName(platform, arch)}`
const foreign =
runtime.foreignPattern ||
new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true })
removed.push(runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name)
removed.push(
runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name
)
}
}
return removed
@@ -209,9 +455,70 @@ function pruneForeignArchConnectors(runtimeResources, platform, arch) {
return removed
}
/**
* 微信发送运行时打包边界
*/
const SEND_RUNTIME_RELATIVE = ['resources', 'runtime', 'darwin-arm64']
const SEND_RUNTIME_ENTRY = 'tm-wechat-host'
function sendRuntimeLocations(runtimeResources) {
return [
path.join(runtimeResources, ...SEND_RUNTIME_RELATIVE),
path.join(runtimeResources, 'app.asar.unpacked', ...SEND_RUNTIME_RELATIVE)
]
}
function findSendRuntime(runtimeResources) {
return (
sendRuntimeLocations(runtimeResources).find((directory) =>
existsSync(path.join(directory, SEND_RUNTIME_ENTRY))
) || null
)
}
function isSendRuntimeBuild() {
return process.env.TM_SEND_RUNTIME_BUILD === '1'
}
function enforceSendRuntimeBoundary(
runtimeResources,
platform,
bundlesSendRuntime = isSendRuntimeBuild()
) {
if (platform !== 'darwin') return null
const found = findSendRuntime(runtimeResources)
if (bundlesSendRuntime) {
if (!found) {
throw new Error(
'This macOS build requires the WeChat send runtime but resources/runtime/darwin-arm64 is missing. ' +
'Run `pnpm prepare:wechat-native` first, or point TM_NATIVE_RUNTIME_DIR at the artifact.'
)
}
return found
}
if (found) {
throw new Error(
'macOS bundle must not include the WeChat send runtime: ' +
found +
'. Build with `pnpm build:mac:arm64:send-runtime` (TM_SEND_RUNTIME_BUILD=1) when it is required, ' +
'or fix the resources filter in electron-builder.yml.'
)
}
return null
}
exports.default = async function afterPack(context) {
const runtimeResources = getRuntimeResources(context)
const arch = normalizeBuilderArch(context.arch)
// 边界先判,越早失败越好。
const sendRuntime = enforceSendRuntimeBoundary(runtimeResources, context.electronPlatformName)
if (context.electronPlatformName === 'darwin') {
console.log(
sendRuntime
? `[afterPack] send runtime bundled at ${sendRuntime}`
: '[afterPack] send runtime excluded'
)
}
validateAsarRuntimeDependencies(runtimeResources)
validateReaderSkillRuntime(runtimeResources)
validateSilkWasmRuntime(runtimeResources)
@@ -223,6 +530,8 @@ exports.default = async function afterPack(context) {
'Bundled ffmpeg'
)
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
validateKoffiRuntime(runtimeResources, context.electronPlatformName, arch)
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
@@ -231,25 +540,10 @@ exports.default = async function afterPack(context) {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
stdio: 'ignore'
})
signMacosHelpers(runtimeResources)
const productName = context.packager.appInfo.productFilename
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
}
if (context.electronPlatformName === 'win32') {
const koffiNative = path.join(
context.appOutDir,
'resources',
'app.asar.unpacked',
'node_modules',
'@koromix',
'koffi-win32-x64',
'win32_x64',
'koffi.node'
)
if (!existsSync(koffiNative)) {
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
}
return
}
}
exports.getRuntimeResources = getRuntimeResources
@@ -258,7 +552,17 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
exports.validateKoffiRuntime = validateKoffiRuntime
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
exports.findMacosHelperPaths = findMacosHelperPaths
exports.isMacosCodeValid = isMacosCodeValid
exports.signMacosHelpers = signMacosHelpers
exports.signMacosAppBundle = signMacosAppBundle
exports.findNestedMacosCodePaths = findNestedMacosCodePaths
exports.sendRuntimeLocations = sendRuntimeLocations
exports.findSendRuntime = findSendRuntime
exports.enforceSendRuntimeBoundary = enforceSendRuntimeBoundary
-65
View File
@@ -1,65 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
function normalizePlatform(value) {
if (value === 'win32' || value === 'windows') return 'windows'
if (value === 'darwin' || value === 'macos') return 'darwin'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported connector platform: ${value}`)
}
function normalizeArch(value) {
if (value === 'x64' || value === 'amd64') return 'amd64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported connector architecture: ${value}`)
}
function detectHostArch() {
if (process.platform !== 'darwin') return process.arch
try {
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
encoding: 'utf8'
}).trim()
return arm64Supported === '1' ? 'arm64' : process.arch
} catch {
return process.arch
}
}
function parseTargets() {
const platformArg = process.argv.indexOf('--platform')
const archArg = process.argv.indexOf('--arch')
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
return platforms.flatMap((platform) =>
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
)
}
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
}
for (const target of parseTargets()) {
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
const outputDir = path.join(outputRoot, directoryName)
const outputPath = path.join(
outputDir,
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
)
fs.rmSync(outputDir, { recursive: true, force: true })
fs.mkdirSync(outputDir, { recursive: true })
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
cwd: sourceDir,
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
stdio: 'inherit'
})
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
}
-436
View File
@@ -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] 运行时准备完成')
+201
View File
@@ -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
}
}
+9 -2
View File
@@ -24,8 +24,15 @@ const avatarSvg = (label, color) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
).toString('base64')}`
const localImagePath = '/Users/user/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/fixture_account_1a2b/temp/RWTemp/2026-07/fixture-image-hash.png'
const sampleImage = fs.existsSync(localImagePath)
/**
* 可选的本地样例图(用于人工核对图片区块的排版)。
*
* 走环境变量传入,**不要在源码里写本机路径** —— 微信数据目录会连带暴露
* 系统用户名与账号目录名。不传就退回内置的 SVG 头像占位。
*/
const localImagePath = process.env.REPORT_FIXTURE_IMAGE || ''
const sampleImage =
localImagePath && fs.existsSync(localImagePath)
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
: avatarSvg('图', '#dbeafe')
+140
View File
@@ -0,0 +1,140 @@
/*
* 测试文件的类型检查棘轮(ratchet)。
*
* 背景:`tsconfig.node.json` / `tsconfig.web.json` 的 include 都不含 `tests/`,
* 所以测试里的类型错误对 `pnpm typecheck` 与 CI 完全不可见——已经积累了一批历史债。
*
* 策略:**不阻塞既有债,但禁止新增**。
* - 基线按「文件 → 错误数」记录,而不是只记总数:
* 否则在 A 文件修掉 1 条、同时在 B 文件新增 1 条会互相抵消,棘轮形同虚设。
* - 某个文件的错误数超过基线即失败;新增了带类型错误的文件同样失败。
* - 需要主动下调基线时用 `--update`(只在确实修好了错误之后)。
*
* 用法:
* node scripts/typecheck-tests.cjs # 校验
* node scripts/typecheck-tests.cjs --update # 用当前结果重写基线
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
const { spawnSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const configPath = path.join(projectRoot, 'tsconfig.test.json')
const baselinePath = path.join(projectRoot, 'tests', 'typecheck-baseline.json')
const ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/
const MAX_REPORTED = 20
function runTypeScript() {
// 直接用本地 typescript 包,避免依赖 node_modules/.bin 在各平台的差异。
const tscPath = require.resolve('typescript/bin/tsc')
const result = spawnSync(
process.execPath,
[tscPath, '--noEmit', '--pretty', 'false', '-p', configPath],
{ cwd: projectRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
)
if (result.error) throw result.error
return `${result.stdout || ''}${result.stderr || ''}`
}
function relative(file) {
const rel = path.relative(projectRoot, path.resolve(projectRoot, file))
return rel.split(path.sep).join('/')
}
/** 解析出「文件 → 错误数」与「文件 → 错误信息列表」。 */
function collectErrors(output) {
const counts = new Map()
const details = new Map()
for (const line of output.split('\n')) {
const match = ERROR_LINE.exec(line.trim())
if (!match) continue
const file = relative(match[1])
counts.set(file, (counts.get(file) ?? 0) + 1)
if (!details.has(file)) details.set(file, [])
if (details.get(file).length < 3) details.get(file).push(`${match[2]}:${match[3]} ${match[4]}`)
}
return { counts, details }
}
function readBaseline() {
try {
const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
if (parsed && typeof parsed.files === 'object' && parsed.files !== null) return parsed
} catch {
// 基线缺失或损坏时按「空基线」处理,会在下面明确报错提示。
}
return null
}
function total(counts) {
let sum = 0
for (const value of counts.values()) sum += value
return sum
}
function main() {
if (!fs.existsSync(configPath)) {
console.error(`[typecheck:test] 缺少 ${path.relative(projectRoot, configPath)}`)
process.exit(1)
}
const { counts, details } = collectErrors(runTypeScript())
if (process.argv.includes('--update')) {
const files = Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b)))
const payload = {
note: '测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。',
total: total(counts),
files
}
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
fs.writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
console.log(
`[typecheck:test] 基线已更新:${payload.total} 个错误 / ${Object.keys(files).length} 个文件`
)
return
}
const baseline = readBaseline()
if (!baseline) {
console.error(
`[typecheck:test] 找不到基线 ${path.relative(projectRoot, baselinePath)}。\n` +
' 首次启用请运行:node scripts/typecheck-tests.cjs --update'
)
process.exit(1)
}
const regressions = []
for (const [file, count] of counts) {
const allowed = baseline.files[file] ?? 0
if (count > allowed) regressions.push({ file, count, allowed })
}
const now = total(counts)
const baselineTotal = Number(baseline.total) || 0
const improved = baselineTotal - now
if (regressions.length > 0) {
console.error('[typecheck:test] 测试文件出现新的类型错误 ❌')
console.error(` 基线 ${baselineTotal} → 当前 ${now}(+${now - baselineTotal})\n`)
let printed = 0
for (const item of regressions) {
console.error(` ${item.file} ${item.allowed} → ${item.count}`)
for (const line of details.get(item.file) ?? []) {
if (printed >= MAX_REPORTED) break
console.error(` ${line}`)
printed += 1
}
}
console.error('\n 修好之后用 --update 下调基线(不要为了过检查而放宽它)。')
process.exit(1)
}
console.log(
`[typecheck:test] PASS ✅ 当前 ${now} 个既有类型错误 / ${counts.size} 个文件` +
(improved > 0 ? `(比基线少 ${improved} 个,可运行 --update 下调)` : '')
)
console.log(' 注意:这是棘轮,只保证「不新增」。修完历史债后可改为阻断式检查。')
}
main()
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 fastclaw-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-25
View File
@@ -1,25 +0,0 @@
# TraceMemo WeChat Connector
This repository-local service provides the minimal WeChat bridge required by TraceMemo:
- QR-code login with a single persisted credential
- account discovery
- inbound long polling and authenticated webhook delivery
- local HTTP health and send endpoints
- text and local/remote media sending
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
## Commands
```bash
go run . login --json
go run . accounts --json
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
```
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. This legacy directory name is intentionally retained so upgrades can reuse existing accounts. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
## Attribution
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). TraceMemo-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding TraceMemo project.
-135
View File
@@ -1,135 +0,0 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
)
// Server provides an HTTP API for sending messages.
type Server struct {
clients []*ilink.Client
addr string
}
// NewServer creates an API server.
func NewServer(clients []*ilink.Client, addr string) *Server {
if addr == "" {
addr = "127.0.0.1:18011"
}
return &Server{clients: clients, addr: addr}
}
// SendRequest is the JSON body for POST /api/send.
type SendRequest struct {
AccountID string `json:"account_id,omitempty"`
To string `json:"to"`
Text string `json:"text,omitempty"`
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
}
// Run starts the HTTP server. Blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/send", s.handleSend)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
srv.Shutdown(context.Background())
}()
log.Printf("[api] listening on %s", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.To == "" {
http.Error(w, `"to" is required`, http.StatusBadRequest)
return
}
if req.Text == "" && req.MediaURL == "" {
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
return
}
if len(s.clients) == 0 {
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
return
}
client := s.clientForAccount(req.AccountID)
if client == nil {
http.Error(w, "requested account is not available", http.StatusNotFound)
return
}
ctx := r.Context()
// Send text if provided
if req.Text != "" {
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
log.Printf("[api] send text failed: %v", err)
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
// Extract and send any markdown images embedded in text
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
log.Printf("[api] send extracted image failed: %v", err)
} else {
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
}
}
}
// Send media if provided
if req.MediaURL != "" {
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
log.Printf("[api] send media failed: %v", err)
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) clientForAccount(accountID string) *ilink.Client {
if accountID == "" {
return s.clients[0]
}
for _, client := range s.clients {
if client.BotID() == accountID {
return client
}
}
return nil
}
@@ -1,20 +0,0 @@
package api
import (
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
if got := server.clientForAccount("bot-new"); got != newClient {
t.Fatal("clientForAccount did not select the requested account")
}
if got := server.clientForAccount("missing"); got != nil {
t.Fatal("clientForAccount should reject an unknown account")
}
}
-8
View File
@@ -1,8 +0,0 @@
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
go 1.23.0
require (
github.com/google/uuid v1.6.0
rsc.io/qr v0.2.0
)
-4
View File
@@ -1,4 +0,0 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
-236
View File
@@ -1,236 +0,0 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
statusWait = "wait"
statusScanned = "scaned"
statusConfirmed = "confirmed"
statusExpired = "expired"
)
// FetchQRCode retrieves a new QR code for login.
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
c := NewUnauthenticatedClient()
var resp QRCodeResponse
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
return nil, fmt.Errorf("fetch QR code: %w", err)
}
return &resp, nil
}
// PollQRStatus polls for QR code scan status until confirmed or expired.
// It calls onStatus for each status change so the caller can display progress.
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
c := NewUnauthenticatedClient()
url := qrStatusURL + qrcode
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
var resp QRStatusResponse
err := c.doGet(pollCtx, url, &resp)
cancel()
if err != nil {
// Timeout is normal for long-poll, retry
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
if onStatus != nil {
onStatus(resp.Status)
}
switch resp.Status {
case statusConfirmed:
creds := &Credentials{
BotToken: resp.BotToken,
ILinkBotID: resp.ILinkBotID,
BaseURL: resp.BaseURL,
ILinkUserID: resp.ILinkUserID,
}
return creds, nil
case statusExpired:
return nil, fmt.Errorf("QR code expired")
case statusWait, statusScanned:
// Continue polling
default:
// Unknown status, continue
}
}
}
func accountsDir(rootName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil
}
// AccountsDir returns the TraceMemo directory where new credentials are stored.
func AccountsDir() (string, error) {
return accountsDir(".tracememo")
}
// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier.
func LegacyAccountsDir() (string, error) {
return accountsDir(".wechatexplorer")
}
func accountDirectoryForID(accountID string) (string, error) {
current, err := AccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil {
return current, nil
}
legacy, err := LegacyAccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil {
return legacy, nil
}
return current, nil
}
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
func NormalizeAccountID(raw string) string {
s := raw
for _, ch := range []string{"@", ".", ":"} {
s = filepath.Clean(s)
s = replaceAll(s, ch, "-")
}
return s
}
func replaceAll(s, old, new string) string {
for {
i := indexOf(s, old)
if i < 0 {
return s
}
s = s[:i] + new + s[i+len(old):]
}
}
func indexOf(s, sub string) int {
for i := range s {
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// SaveCredentials saves the latest credentials and removes older accounts.
// The new credential is written first so a failed login never destroys the
// previously working credential.
func SaveCredentials(creds *Credentials) error {
dir, err := AccountsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create accounts dir: %w", err)
}
id := NormalizeAccountID(creds.ILinkBotID)
path := filepath.Join(dir, id+".json")
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("prune old credentials: %w", err)
}
keepPrefix := id + "."
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
continue
}
if filepath.Ext(entry.Name()) != ".json" {
continue
}
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
}
}
return nil
}
func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read accounts dir: %w", err)
}
var result []*Credentials
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var creds Credentials
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
result = append(result, &creds)
}
}
return result, nil
}
// LoadAllCredentials loads TraceMemo credentials first and falls back to the
// untouched WechatExplorer directory for one-version upgrade compatibility.
func LoadAllCredentials() ([]*Credentials, error) {
current, err := AccountsDir()
if err != nil {
return nil, err
}
credentials, err := loadCredentialsFromDir(current)
if err != nil || len(credentials) > 0 {
return credentials, err
}
legacy, err := LegacyAccountsDir()
if err != nil {
return nil, err
}
return loadCredentialsFromDir(legacy)
}
// CredentialsPath returns the path for display purposes.
func CredentialsPath() (string, error) {
return AccountsDir()
}
@@ -1,86 +0,0 @@
package ilink
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func setTestHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
return home
}
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
setTestHome(t)
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
if err := SaveCredentials(old); err != nil {
t.Fatal(err)
}
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if err := SaveCredentials(latest); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
t.Fatalf("old sync state still exists: %v", err)
}
}
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
home := setTestHome(t)
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".tracememo", "wechat-connector", "accounts")
if dir != want {
t.Fatalf("AccountsDir() = %q, want %q", dir, want)
}
}
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
setTestHome(t)
legacyDir, err := LegacyAccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
t.Fatal(err)
}
legacy := &Credentials{ILinkBotID: "legacy@im.bot", BotToken: "legacy-token"}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyDir, NormalizeAccountID(legacy.ILinkBotID)+".json"), data, 0o600); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].BotToken != legacy.BotToken {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(legacyDir); err != nil {
t.Fatalf("legacy directory changed or removed: %v", err)
}
}
-218
View File
@@ -1,218 +0,0 @@
package ilink
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
defaultBaseURL = "https://ilinkai.weixin.qq.com"
longPollTimeout = 35 * time.Second
sendTimeout = 15 * time.Second
)
// Client is an iLink HTTP API client.
type Client struct {
baseURL string
botToken string
botID string
httpClient *http.Client
wechatUIN string
}
// NewClient creates a new iLink API client.
func NewClient(creds *Credentials) *Client {
baseURL := creds.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{
baseURL: baseURL,
botToken: creds.BotToken,
botID: creds.ILinkBotID,
httpClient: &http.Client{},
wechatUIN: generateWechatUIN(),
}
}
// NewUnauthenticatedClient creates a client without credentials for login flow.
func NewUnauthenticatedClient() *Client {
return &Client{
baseURL: defaultBaseURL,
httpClient: &http.Client{Timeout: 40 * time.Second},
wechatUIN: generateWechatUIN(),
}
}
// BotID returns the bot's user ID.
func (c *Client) BotID() string {
return c.botID
}
// GetUpdates performs a long-poll for new messages.
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
reqBody := GetUpdatesRequest{
GetUpdatesBuf: buf,
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
}
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
defer cancel()
var resp GetUpdatesResponse
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendMessage sends a message through iLink.
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp SendMessageResponse
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetConfig fetches bot config for a user (includes typing_ticket).
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := GetConfigRequest{
ILinkUserID: userID,
ContextToken: contextToken,
BaseInfo: BaseInfo{},
}
var resp GetConfigResponse
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendTyping sends a typing indicator to a user.
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := SendTypingRequest{
ILinkUserID: userID,
TypingTicket: typingTicket,
Status: status,
BaseInfo: BaseInfo{},
}
var resp SendTypingResponse
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
return err
}
if resp.Ret != 0 {
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
return nil
}
// GetUploadURL gets a pre-signed CDN upload URL for media files.
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp GetUploadURLResponse
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// BaseURL returns the base URL for CDN operations.
func (c *Client) BaseURL() string {
return c.baseURL
}
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("Authorization", "Bearer "+c.botToken)
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
}
func generateWechatUIN() string {
var n uint32
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
s := fmt.Sprintf("%d", n)
return base64.StdEncoding.EncodeToString([]byte(s))
}
-181
View File
@@ -1,181 +0,0 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
const (
maxConsecutiveFailures = 5
initialBackoff = 3 * time.Second
maxBackoff = 60 * time.Second
sessionExpiredBackoff = 5 * time.Second
errCodeSessionExpired = -14
)
// MessageHandler is called for each received message.
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
// Monitor manages the long-poll loop for receiving messages.
type Monitor struct {
client *Client
handler MessageHandler
getUpdatesBuf string
bufPath string
failures int
lastActivity time.Time
}
// NewMonitor creates a new long-poll monitor.
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
accountID := NormalizeAccountID(client.BotID())
accountsRoot, err := accountDirectoryForID(accountID)
if err != nil {
return nil, err
}
bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
m := &Monitor{
client: client,
handler: handler,
bufPath: bufPath,
lastActivity: time.Now(),
}
m.loadBuf()
return m, nil
}
// Run starts the long-poll loop. It blocks until ctx is cancelled.
// Automatically recovers from errors with exponential backoff.
func (m *Monitor) Run(ctx context.Context) error {
log.Println("[monitor] starting long-poll loop")
for {
select {
case <-ctx.Done():
log.Println("[monitor] shutting down")
return ctx.Err()
default:
}
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
m.failures++
backoff := m.calcBackoff()
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
m.failures, maxConsecutiveFailures, backoff, err)
if m.failures == maxConsecutiveFailures {
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures)
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Reset failure counter on any successful response
m.failures = 0
m.lastActivity = time.Now()
// Session expired — reset sync buf and reconnect silently
if resp.ErrCode == errCodeSessionExpired {
if m.getUpdatesBuf != "" {
log.Printf("[monitor] session expired, resetting sync buf")
m.getUpdatesBuf = ""
m.saveBuf()
} else {
// Sync buf already empty but still getting session expired:
// the bot token itself has expired. The user needs to re-login.
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.")
}
select {
case <-time.After(sessionExpiredBackoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Other server errors
if resp.Ret != 0 && resp.ErrCode != 0 {
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
continue
}
// Update buf for next poll
if resp.GetUpdatesBuf != "" {
m.getUpdatesBuf = resp.GetUpdatesBuf
m.saveBuf()
}
// Process messages concurrently — don't block the poll loop
for _, msg := range resp.Msgs {
go m.handler(ctx, m.client, msg)
}
}
}
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
func (m *Monitor) calcBackoff() time.Duration {
d := initialBackoff
for i := 1; i < m.failures; i++ {
d *= 2
if d > maxBackoff {
return maxBackoff
}
}
return d
}
type syncData struct {
GetUpdatesBuf string `json:"get_updates_buf"`
}
func (m *Monitor) loadBuf() {
data, err := os.ReadFile(m.bufPath)
if err != nil {
return
}
var s syncData
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
m.getUpdatesBuf = s.GetUpdatesBuf
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
}
}
func (m *Monitor) saveBuf() {
dir := filepath.Dir(m.bufPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("[monitor] failed to create buf dir: %v", err)
return
}
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
log.Printf("[monitor] failed to save buf: %v", err)
}
}
// FormatMessageSummary returns a short description of a message for logging.
func FormatMessageSummary(msg WeixinMessage) string {
text := ""
for _, item := range msg.ItemList {
if item.Type == ItemTypeText && item.TextItem != nil {
text = item.TextItem.Text
break
}
}
if len(text) > 50 {
text = text[:50] + "..."
}
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
}
-219
View File
@@ -1,219 +0,0 @@
package ilink
// Message types
const (
MessageTypeNone = 0
MessageTypeUser = 1
MessageTypeBot = 2
)
// Message states
const (
MessageStateNew = 0
MessageStateGenerating = 1
MessageStateFinish = 2
)
// Item types
const (
ItemTypeNone = 0
ItemTypeText = 1
ItemTypeImage = 2
ItemTypeVoice = 3
ItemTypeFile = 4
ItemTypeVideo = 5
)
// QRCodeResponse is the response from get_bot_qrcode.
type QRCodeResponse struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
// QRStatusResponse is the response from get_qrcode_status.
type QRStatusResponse struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// Credentials stores login session data.
type Credentials struct {
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// BaseInfo is included in request bodies.
type BaseInfo struct {
ChannelVersion string `json:"channel_version,omitempty"`
}
// GetUpdatesRequest is the body for getupdates.
type GetUpdatesRequest struct {
GetUpdatesBuf string `json:"get_updates_buf"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUpdatesResponse is the response from getupdates.
type GetUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Msgs []WeixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
}
// WeixinMessage represents a message from WeChat.
type WeixinMessage struct {
Seq int `json:"seq,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// MessageItem is a single item in a message.
type MessageItem struct {
Type int `json:"type"`
TextItem *TextItem `json:"text_item,omitempty"`
ImageItem *ImageItem `json:"image_item,omitempty"`
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
VideoItem *VideoItem `json:"video_item,omitempty"`
FileItem *FileItem `json:"file_item,omitempty"`
}
// CDN media type constants.
const (
CDNMediaTypeImage = 1
CDNMediaTypeVideo = 2
CDNMediaTypeFile = 3
)
// GetUploadURLRequest is the body for getuploadurl.
type GetUploadURLRequest struct {
FileKey string `json:"filekey"`
MediaType int `json:"media_type"`
ToUserID string `json:"to_user_id"`
RawSize int `json:"rawsize"`
RawFileMD5 string `json:"rawfilemd5"`
FileSize int `json:"filesize"`
NoNeedThumb bool `json:"no_need_thumb"`
AESKey string `json:"aeskey"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUploadURLResponse is the response from getuploadurl.
type GetUploadURLResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
UploadParam string `json:"upload_param"`
UploadFullURL string `json:"upload_full_url,omitempty"`
}
// TextItem holds text content.
type TextItem struct {
Text string `json:"text"`
}
// MediaInfo holds CDN media reference for uploaded files.
type MediaInfo struct {
EncryptQueryParam string `json:"encrypt_query_param"`
AESKey string `json:"aes_key"` // base64-encoded
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
}
// VoiceItem holds voice content.
type VoiceItem struct {
Media *MediaInfo `json:"media,omitempty"`
VoiceSize int `json:"voice_size,omitempty"`
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
BitsPerSample int `json:"bits_per_sample,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` // Hz
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
}
// ImageItem holds image content.
type ImageItem struct {
URL string `json:"url,omitempty"`
Media *MediaInfo `json:"media,omitempty"`
MidSize int `json:"mid_size,omitempty"` // ciphertext size
}
// VideoItem holds video content.
type VideoItem struct {
Media *MediaInfo `json:"media,omitempty"`
VideoSize int `json:"video_size,omitempty"`
}
// FileItem holds file content.
type FileItem struct {
Media *MediaInfo `json:"media,omitempty"`
FileName string `json:"file_name,omitempty"`
Len string `json:"len,omitempty"` // plaintext size as string
}
// SendMessageRequest is the body for sendmessage.
type SendMessageRequest struct {
Msg SendMsg `json:"msg"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendMsg is the message payload for sending.
type SendMsg struct {
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
ClientID string `json:"client_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// SendMessageResponse is the response from sendmessage.
type SendMessageResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
// Typing status constants.
const (
TypingStatusTyping = 1
TypingStatusCancel = 2
)
// GetConfigRequest is the body for getconfig.
type GetConfigRequest struct {
ILinkUserID string `json:"ilink_user_id"`
ContextToken string `json:"context_token,omitempty"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetConfigResponse is the response from getconfig.
type GetConfigResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
TypingTicket string `json:"typing_ticket,omitempty"`
}
// SendTypingRequest is the body for sendtyping.
type SendTypingRequest struct {
ILinkUserID string `json:"ilink_user_id"`
TypingTicket string `json:"typing_ticket"`
Status int `json:"status"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendTypingResponse is the response from sendtyping.
type SendTypingResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
-200
View File
@@ -1,200 +0,0 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
"rsc.io/qr"
)
type loginEvent struct {
Status string `json:"status"`
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
AccountID string `json:"account_id,omitempty"`
WeChatUserID string `json:"wechat_user_id,omitempty"`
}
type accountSummary struct {
AccountID string `json:"account_id"`
WeChatUserID string `json:"wechat_user_id"`
}
func main() {
if len(os.Args) < 2 {
fatal(errors.New("expected one of: login, accounts, start"))
}
var err error
switch os.Args[1] {
case "login":
err = runLogin(os.Args[2:])
case "accounts":
err = runAccounts(os.Args[2:])
case "start":
err = runStart(os.Args[2:])
default:
err = fmt.Errorf("unknown command %q", os.Args[1])
}
if err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
}
func runLogin(args []string) error {
flags := flag.NewFlagSet("login", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
if err := flags.Parse(args); err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
creds, err := login(ctx, *jsonOutput)
if err != nil {
return err
}
if !*jsonOutput {
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
}
return nil
}
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
qrResponse, err := ilink.FetchQRCode(ctx)
if err != nil {
return nil, err
}
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
if err != nil {
return nil, fmt.Errorf("encode QR image: %w", err)
}
emit := func(event loginEvent) {
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(event)
}
}
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
lastStatus := ""
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
if status != lastStatus {
lastStatus = status
emit(loginEvent{Status: status})
}
})
if err != nil {
return nil, err
}
if err := ilink.SaveCredentials(creds); err != nil {
return nil, fmt.Errorf("save credentials: %w", err)
}
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
return creds, nil
}
func runAccounts(args []string) error {
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "print JSON")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
items := make([]accountSummary, 0, len(accounts))
for _, account := range accounts {
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
}
if *jsonOutput {
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
}
for _, item := range items {
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
}
return nil
}
func runStart(args []string) error {
flags := flag.NewFlagSet("start", flag.ContinueOnError)
_ = flags.Bool("foreground", false, "kept for host compatibility")
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
accountID := flags.String("account-id", "", "account to start")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
if len(accounts) == 0 {
return errors.New("no connected WeChat account; scan a QR code first")
}
selected := accounts[len(accounts)-1]
if *accountID != "" {
selected = nil
for _, account := range accounts {
if account.ILinkBotID == *accountID {
selected = account
break
}
}
if selected == nil {
return fmt.Errorf("account %q not found", *accountID)
}
}
ctx, cancel := signalContext()
defer cancel()
client := ilink.NewClient(selected)
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
if webhookURL != "" {
webhook.Dispatch(messageContext, source, message)
}
})
if err != nil {
return err
}
var wait sync.WaitGroup
wait.Add(2)
go func() {
defer wait.Done()
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[api] stopped: %v", err)
cancel()
}
}()
go func() {
defer wait.Done()
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[monitor] stopped: %v", err)
cancel()
}
}()
wait.Wait()
return nil
}
-232
View File
@@ -1,232 +0,0 @@
package messaging
import (
"bytes"
"context"
"crypto/aes"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
// UploadedFile holds the result of a CDN upload.
type UploadedFile struct {
DownloadParam string // encrypted query param for download
AESKeyHex string // hex-encoded AES key
FileSize int // plaintext size
CipherSize int // ciphertext size
}
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
// Generate random filekey and AES key
filekey := make([]byte, 16)
aeskey := make([]byte, 16)
if _, err := rand.Read(filekey); err != nil {
return nil, fmt.Errorf("generate filekey: %w", err)
}
if _, err := rand.Read(aeskey); err != nil {
return nil, fmt.Errorf("generate aeskey: %w", err)
}
filekeyHex := hex.EncodeToString(filekey)
aeskeyHex := hex.EncodeToString(aeskey)
// Calculate MD5 of plaintext
hash := md5.Sum(data)
rawMD5 := hex.EncodeToString(hash[:])
// Calculate ciphertext size (PKCS7 padding)
cipherSize := aesECBPaddedSize(len(data))
// Get upload URL from iLink API
uploadReq := &ilink.GetUploadURLRequest{
FileKey: filekeyHex,
MediaType: mediaType,
ToUserID: toUserID,
RawSize: len(data),
RawFileMD5: rawMD5,
FileSize: cipherSize,
NoNeedThumb: true,
AESKey: aeskeyHex,
BaseInfo: ilink.BaseInfo{},
}
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
if err != nil {
return nil, fmt.Errorf("get upload URL: %w", err)
}
if uploadResp.Ret != 0 {
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
}
// Encrypt data with AES-128-ECB
encrypted, err := encryptAESECB(data, aeskey)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
if cdnURL == "" {
if uploadResp.UploadParam == "" {
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
}
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
}
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
if err != nil {
return nil, fmt.Errorf("CDN upload: %w", err)
}
return &UploadedFile{
DownloadParam: downloadParam,
AESKeyHex: aeskeyHex,
FileSize: len(data),
CipherSize: cipherSize,
}, nil
}
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
func AESKeyToBase64(hexKey string) string {
return base64.StdEncoding.EncodeToString([]byte(hexKey))
}
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
// Decode AES key: base64 -> hex string -> raw bytes
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
if err != nil {
return nil, fmt.Errorf("decode AES key base64: %w", err)
}
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
if err != nil {
return nil, fmt.Errorf("decode AES key hex: %w", err)
}
// Download encrypted data from CDN
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
cdnBaseURL, url.QueryEscape(encryptQueryParam))
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download from CDN: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
}
encrypted, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CDN response: %w", err)
}
// Decrypt AES-128-ECB
return decryptAESECB(encrypted, aesKey)
}
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Remove PKCS7 padding
if len(plaintext) == 0 {
return plaintext, nil
}
padLen := int(plaintext[len(plaintext)-1])
if padLen > aes.BlockSize || padLen == 0 {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
return plaintext[:len(plaintext)-padLen], nil
}
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
}
downloadParam := resp.Header.Get("X-Encrypted-Param")
if downloadParam == "" {
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
}
return downloadParam, nil
}
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7 padding
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
// ECB mode: encrypt each block independently
encrypted := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
return encrypted, nil
}
func aesECBPaddedSize(plaintextSize int) int {
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
}
@@ -1,121 +0,0 @@
package messaging
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const (
webhookAttempts = 3
webhookTimeout = 5 * time.Second
)
type InboundWebhook struct {
url string
token string
client *http.Client
}
type inboundWebhookPayload struct {
AccountID string `json:"account_id"`
FromUserID string `json:"from_user_id"`
MessageID int64 `json:"message_id"`
MessageType int `json:"message_type"`
Items []inboundWebhookItem `json:"items"`
ReceivedAt time.Time `json:"received_at"`
}
type inboundWebhookItem struct {
Type int `json:"type"`
Text string `json:"text,omitempty"`
}
func NewInboundWebhook(url, token string) *InboundWebhook {
return &InboundWebhook{
url: strings.TrimSpace(url),
token: token,
client: &http.Client{Timeout: webhookTimeout},
}
}
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
payload := normalizeInboundMessage(client.BotID(), msg)
go func() {
if err := w.deliver(ctx, payload); err != nil {
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
}
}()
}
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode payload: %w", err)
}
var lastErr error
for attempt := 1; attempt <= webhookAttempts; attempt++ {
if attempt > 1 {
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
if reqErr != nil {
return fmt.Errorf("create request: %w", reqErr)
}
req.Header.Set("Content-Type", "application/json")
if w.token != "" {
req.Header.Set("Authorization", "Bearer "+w.token)
}
resp, doErr := w.client.Do(req)
if doErr != nil {
lastErr = doErr
continue
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
break
}
}
return lastErr
}
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
for _, item := range msg.ItemList {
normalized := inboundWebhookItem{Type: item.Type}
if item.TextItem != nil {
normalized.Text = item.TextItem.Text
} else if item.VoiceItem != nil {
normalized.Text = item.VoiceItem.Text
}
items = append(items, normalized)
}
return inboundWebhookPayload{
AccountID: accountID,
FromUserID: msg.FromUserID,
MessageID: msg.MessageID,
MessageType: msg.MessageType,
Items: items,
ReceivedAt: time.Now().UTC(),
}
}
@@ -1,75 +0,0 @@
package messaging
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
var got inboundWebhookPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "secret")
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
}))
if err != nil {
t.Fatalf("deliver: %v", err)
}
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
t.Fatalf("payload = %#v", got)
}
}
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < 3 {
http.Error(w, "temporary", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
t.Fatalf("deliver: %v", err)
}
if calls.Load() != 3 {
t.Fatalf("calls = %d, want 3", calls.Load())
}
}
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
t.Fatal("deliver error = nil")
}
if calls.Load() != 1 {
t.Fatalf("calls = %d, want 1", calls.Load())
}
}
@@ -1,103 +0,0 @@
package messaging
import (
"regexp"
"strings"
)
var (
// Code blocks: strip fences, keep code content
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
// Inline code: strip backticks, keep content
reInlineCode = regexp.MustCompile("`([^`]+)`")
// Images: remove entirely
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
// Links: keep display text only
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
// Table separator rows: remove
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
// Table rows: convert pipe-delimited to space-delimited
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
// Headers: remove # prefix
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
// Bold: **text** or __text__
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
// Italic: *text* or _text_
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
// Strikethrough: ~~text~~
reStrike = regexp.MustCompile(`~~(.+?)~~`)
// Blockquote: > prefix
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
// Horizontal rule
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
// Unordered list markers: -, *, +
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
)
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
func MarkdownToPlainText(text string) string {
result := text
// Code blocks: strip fences, keep code content
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
parts := reCodeBlock.FindStringSubmatch(match)
if len(parts) > 1 {
return strings.TrimSpace(parts[1])
}
return match
})
// Images: remove entirely
result = reImage.ReplaceAllString(result, "")
// Links: keep display text only
result = reLink.ReplaceAllString(result, "$1")
// Table separator rows: remove
result = reTableSep.ReplaceAllString(result, "")
// Table rows: pipe-delimited to space-delimited
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
parts := reTableRow.FindStringSubmatch(match)
if len(parts) > 1 {
cells := strings.Split(parts[1], "|")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return strings.Join(cells, " ")
}
return match
})
// Headers: remove # prefix
result = reHeader.ReplaceAllString(result, "")
// Bold
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
parts := reBold.FindStringSubmatch(match)
if parts[1] != "" {
return parts[1]
}
return parts[2]
})
// Strikethrough
result = reStrike.ReplaceAllString(result, "$1")
// Blockquote
result = reBlockquote.ReplaceAllString(result, "")
// Horizontal rule -> empty line
result = reHR.ReplaceAllString(result, "")
// Unordered list: replace markers with "• "
result = reUL.ReplaceAllString(result, "${1}• ")
// Inline code: strip backticks (do after code blocks)
result = reInlineCode.ReplaceAllString(result, "$1")
// Clean up excessive blank lines
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
return strings.TrimSpace(result)
}
@@ -1,221 +0,0 @@
package messaging
import (
"context"
"fmt"
"io"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
// reMarkdownImage matches markdown image syntax: ![alt](url)
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
// ExtractImageURLs extracts image URLs from markdown text.
func ExtractImageURLs(text string) []string {
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
var urls []string
for _, m := range matches {
url := strings.TrimSpace(m[1])
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
urls = append(urls, url)
}
}
return urls
}
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
// Check if it's a local file
if _, err := os.Stat(mediaURL); err == nil {
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
}
// Must be a valid HTTP URL to download
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
}
data, contentType, err := downloadFile(ctx, mediaURL)
if err != nil {
return fmt.Errorf("download %s: %w", mediaURL, err)
}
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
}
// SendMediaFromPath reads a local file and sends it as a media message.
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
}
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
if fileName == "" {
fileName = "file"
}
cdnMediaType, itemType := classifyMedia(contentType, source)
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
if err != nil {
return fmt.Errorf("upload to CDN: %w", err)
}
media := &ilink.MediaInfo{
EncryptQueryParam: uploaded.DownloadParam,
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
EncryptType: 1,
}
var item ilink.MessageItem
switch itemType {
case ilink.ItemTypeImage:
item = ilink.MessageItem{
Type: ilink.ItemTypeImage,
ImageItem: &ilink.ImageItem{
Media: media,
MidSize: uploaded.CipherSize,
},
}
case ilink.ItemTypeVideo:
item = ilink.MessageItem{
Type: ilink.ItemTypeVideo,
VideoItem: &ilink.VideoItem{
Media: media,
VideoSize: uploaded.CipherSize,
},
}
default:
item = ilink.MessageItem{
Type: ilink.ItemTypeFile,
FileItem: &ilink.FileItem{
Media: media,
FileName: fileName,
Len: fmt.Sprintf("%d", uploaded.FileSize),
},
}
}
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: NewClientID(),
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{item},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send media message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
return nil
}
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = inferContentType(url)
}
return data, contentType, nil
}
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
ct := strings.ToLower(contentType)
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
}
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
}
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
}
func isImageExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
return true
}
return false
}
func isVideoExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
return true
}
return false
}
func inferContentType(url string) string {
ext := filepath.Ext(stripQuery(url))
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func filenameFromURL(rawURL string) string {
u := stripQuery(rawURL)
name := filepath.Base(u)
if name == "" || name == "." || name == "/" {
return "file"
}
return name
}
func stripQuery(rawURL string) string {
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
return rawURL[:i]
}
return rawURL
}
@@ -1,73 +0,0 @@
package messaging
import "testing"
func TestExtractImageURLs(t *testing.T) {
text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)"
urls := ExtractImageURLs(text)
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %d", len(urls))
}
if urls[0] != "https://example.com/a.png" {
t.Errorf("urls[0] = %q", urls[0])
}
if urls[1] != "https://example.com/b.jpg" {
t.Errorf("urls[1] = %q", urls[1])
}
}
func TestExtractImageURLs_NoImages(t *testing.T) {
urls := ExtractImageURLs("just plain text")
if len(urls) != 0 {
t.Errorf("expected 0 urls, got %d", len(urls))
}
}
func TestExtractImageURLs_RelativeURL(t *testing.T) {
text := "![img](./local.png)"
urls := ExtractImageURLs(text)
if len(urls) != 0 {
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
}
}
func TestFilenameFromURL(t *testing.T) {
tests := []struct {
url string
want string
}{
{"https://example.com/photo.png", "photo.png"},
{"https://example.com/path/to/report.pdf", "report.pdf"},
{"https://example.com/file", "file"},
}
for _, tt := range tests {
got := filenameFromURL(tt.url)
if got != tt.want {
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
}
}
}
func TestFilenameFromURL_WithQuery(t *testing.T) {
got := filenameFromURL("https://example.com/photo.png?token=abc")
if got != "photo.png" {
t.Errorf("got %q, want %q", got, "photo.png")
}
}
func TestStripQuery(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://example.com/a?b=c", "https://example.com/a"},
{"https://example.com/a", "https://example.com/a"},
{"https://example.com/?x=1&y=2", "https://example.com/"},
}
for _, tt := range tests {
got := stripQuery(tt.input)
if got != tt.want {
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -1,86 +0,0 @@
package messaging
import (
"context"
"fmt"
"log"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/google/uuid"
)
// NewClientID generates a new unique client ID for message correlation.
func NewClientID() string {
return uuid.New().String()
}
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
// It first fetches a typing_ticket via getconfig, then sends the typing status.
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
// Get typing ticket
configResp, err := client.GetConfig(ctx, userID, contextToken)
if err != nil {
return fmt.Errorf("get config for typing: %w", err)
}
if configResp.TypingTicket == "" {
return fmt.Errorf("no typing_ticket returned from getconfig")
}
// Send typing
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
return fmt.Errorf("send typing: %w", err)
}
log.Printf("[sender] sent typing indicator to %s", userID)
return nil
}
// SendTextReply sends a text reply to a user through the iLink API.
// If clientID is empty, a new one is generated.
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
if clientID == "" {
clientID = NewClientID()
}
// Convert markdown to plain text for WeChat display
plainText := MarkdownToPlainText(text)
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: clientID,
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{
{
Type: ilink.ItemTypeText,
TextItem: &ilink.TextItem{
Text: plainText,
},
},
},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
return nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+5
View File
@@ -1246,10 +1246,15 @@ async function runSingleExport(
const wavChannels =
audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1
const pcmBytes = Math.max(0, audioBuffer.length - 44)
// 口径统一:消息解析阶段已从 <voicemsg voicelength> 拿到微信的原始秒数(带小数),
// 它是唯一权威来源,不要覆盖。只有拿不到时才退回用 PCM 字节数估算——
// 那份估算是整秒、且下限 1 秒(WAV 缺失头部时的兜底),语义不同。
if (message.voiceDuration == null) {
message.voiceDuration = Math.max(
1,
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
)
}
} catch (error) {
keepMediaError(
request,
+160 -56
View File
@@ -13,6 +13,8 @@ import {
GroupReportRenderSnapshotExportRequest,
ReportHeat,
ReportSectionMeta,
buildReportAvatarAliasIndex,
mergeReportAvatars,
selectHeroParticipantNames
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
@@ -99,43 +101,141 @@ const fallbackAvatar = (name: string): RenderedAvatar => {
const hue = hashName(name) % 360
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
return { source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, fallback: true }
return {
source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
fallback: true
}
}
const imageMimeType = (contentType: string | null, source: string): string => {
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
const extension = path.extname(source).toLowerCase()
if (extension === '.png') return 'image/png'
if (extension === '.webp') return 'image/webp'
if (extension === '.gif') return 'image/gif'
return 'image/jpeg'
/**
* 只认真实图片字节。
*
* `content-type` 与 URL 扩展名都**不可信**:微信 CDN 在限流 / 反盗链时会返回 200 + HTML 正文。
* 旧实现按扩展名猜 mime 并默认 `image/jpeg`,会把 HTML 内联成"解码失败的 data URL" ——
* 在报告里表现为**空白头像**(比首字占位更糟:用户看不到任何东西,也不知道为什么)。
*/
const IMAGE_MAGIC: Array<{ mime: string; bytes: number[] }> = [
{ mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
{ mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
{ mime: 'image/bmp', bytes: [0x42, 0x4d] }
]
export const detectImageMime = (bytes: Buffer): string | undefined => {
for (const signature of IMAGE_MAGIC) {
if (
bytes.length >= signature.bytes.length &&
signature.bytes.every((byte, index) => bytes[index] === byte)
) {
return signature.mime
}
}
if (
bytes.length >= 12 &&
bytes.toString('ascii', 0, 4) === 'RIFF' &&
bytes.toString('ascii', 8, 12) === 'WEBP'
) {
return 'image/webp'
}
const head = bytes.subarray(0, 64).toString('utf8').trimStart()
if (head.startsWith('<svg')) return 'image/svg+xml'
if (head.startsWith('<?xml') && head.includes('<svg')) return 'image/svg+xml'
return undefined
}
const embedAvatar = async (source: string | undefined, name: string): Promise<RenderedAvatar> => {
if (!source) return fallbackAvatar(name)
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return { source, fallback: false }
const AVATAR_FETCH_TIMEOUT_MS = 8000
/** 首次 + 一次重试:单次瞬时失败(限流 / 连接重置 / 超时)不该让一个人永久退回首字。 */
const AVATAR_FETCH_ATTEMPTS = 2
/** 同一 origin 的并发上限。几十个头像同时打一个 CDN 会显著抬高被限流的概率。 */
const AVATAR_FETCH_CONCURRENCY = 6
/** 进程内头像缓存条目上限(老报告重渲染 / 连续生成同一群时不必重复下载)。 */
const AVATAR_CACHE_LIMIT = 256
try {
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const avatarEmbedCache = new Map<string, RenderedAvatar>()
const rememberAvatarEmbed = (source: string, rendered: RenderedAvatar): void => {
if (rendered.fallback) return
avatarEmbedCache.set(source, rendered)
while (avatarEmbedCache.size > AVATAR_CACHE_LIMIT) {
const oldest = avatarEmbedCache.keys().next().value
if (oldest === undefined) break
avatarEmbedCache.delete(oldest)
}
}
/** 有界并发:把 N 个任务压到 limit 个同时在飞,结果顺序与输入一致。 */
export const mapWithConcurrency = async <T, R>(
items: readonly T[],
limit: number,
task: (item: T) => Promise<R>
): Promise<R[]> => {
const results: R[] = new Array(items.length)
let cursor = 0
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
for (;;) {
const index = cursor
cursor += 1
if (index >= items.length) return
results[index] = await task(items[index])
}
})
await Promise.all(workers)
return results
}
const readAvatarSource = async (source: string): Promise<RenderedAvatar> => {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source, {
headers: {
'User-Agent': 'Mozilla/5.0 TraceMemo',
Referer: 'https://weixin.qq.com/'
},
signal: AbortSignal.timeout(8000)
signal: AbortSignal.timeout(AVATAR_FETCH_TIMEOUT_MS)
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const mime = imageMimeType(response.headers.get('content-type'), source)
return { source: `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`, fallback: false }
const bytes = Buffer.from(await response.arrayBuffer())
const mime = detectImageMime(bytes)
if (!mime) {
throw new Error(
`not an image (content-type=${response.headers.get('content-type') || 'unknown'}, ${bytes.length} bytes)`
)
}
return { source: `data:${mime};base64,${bytes.toString('base64')}`, fallback: false }
}
const localPath = source.startsWith('file://') ? new URL(source) : source
const buffer = await fs.readFile(localPath)
return { source: `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`, fallback: false }
} catch (error) {
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
return fallbackAvatar(name)
const bytes = await fs.readFile(localPath)
const mime = detectImageMime(bytes)
if (!mime) throw new Error(`not an image (${bytes.length} bytes)`)
return { source: `data:${mime};base64,${bytes.toString('base64')}`, fallback: false }
}
export const embedAvatar = async (
source: string | undefined,
name: string
): Promise<RenderedAvatar> => {
if (!source) return fallbackAvatar(name)
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source))
return { source, fallback: false }
const cached = avatarEmbedCache.get(source)
if (cached) return cached
let lastError: unknown
for (let attempt = 1; attempt <= AVATAR_FETCH_ATTEMPTS; attempt += 1) {
try {
const embedded = await readAvatarSource(source)
rememberAvatarEmbed(source, embedded)
return embedded
} catch (error) {
lastError = error
if (attempt < AVATAR_FETCH_ATTEMPTS) await sleep(150 * attempt)
}
}
console.warn(`[GroupReport] avatar fallback for ${name}:`, lastError)
return fallbackAvatar(name)
}
/**
@@ -144,6 +244,12 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<Re
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
* - 同名取首条(P2 风险:群里两人同名)
*
* **必须按多个别名建索引**:报告里的显示名取决于 `memberNameMode`
* (默认 `groupNickname` = 群昵称),而快照的 `nickname` 字段是
* `wechatNickname || groupNickname || username`(见 `normalizeGroupMembers`)。
* 一个成员同时有微信昵称与群昵称且两者不同时,只按 `nickname` 建索引就会**全部对不上**,
* 于是头像 enrichment 静默失效 —— 这正是原先只用单一索引键时的问题。
*/
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
if (!metadata.talker) return
@@ -162,22 +268,16 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<vo
return
}
const index = new Map<string, string>()
for (const member of snapshot.members) {
if (member.nickname && member.avatar && !index.has(member.nickname)) {
index.set(member.nickname, member.avatar)
}
}
// 每个成员的所有可用显示名都指向同一个头像 URL;先到先得,避免同名互相覆盖。
// 只按 `member.nickname` 建索引会在"微信昵称 ≠ 群昵称"时全部对不上 —— 见 shared 里的注释。
const index = buildReportAvatarAliasIndex(snapshot.members)
metadata.avatars = metadata.avatars ?? {}
for (const [name, url] of index) {
if (metadata.avatars[name]) continue
metadata.avatars[name] = url
}
const filled = mergeReportAvatars(metadata.avatars, index)
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)`
`enriched ${filled}/${index.size} member avatar aliases from snapshot (${snapshot.memberCount} members)`
)
}
@@ -276,12 +376,22 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
const avatars = new Map<string, RenderedAvatar>()
await Promise.all(
Array.from(avatarNames).map(async (name) => {
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
})
// 有界并发 + 单条重试:几十个头像同时打同一个 CDN 会被限流,瞬时失败会让一个人
// 在整份报告里永久退化成首字占位(实测同一天三次生成:0% / 0% / 32% 失败)。
const renderedAvatars = await mapWithConcurrency(
Array.from(avatarNames),
AVATAR_FETCH_CONCURRENCY,
async (name) => [name, await embedAvatar(metadata.avatars[name], name)] as const
)
const avatars = new Map(renderedAvatars)
const fallbackCount = renderedAvatars.filter(([, item]) => item.fallback).length
if (fallbackCount > 0) {
// 只记数量,不记人名:报告本身已经有名字,这里只需要一个可诊断的信号。
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`avatar fallback ${fallbackCount}/${renderedAvatars.length}: 未取到真实头像,已用首字占位`
)
}
const avatar = (name: string): RenderedAvatar => avatars.get(name) || fallbackAvatar(name)
const renderAvatar = (
name: string,
@@ -295,9 +405,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
}
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
const heroAvatars = heroNames
.map((name) => renderAvatar(name, 'hero', '', name))
.join('')
const heroAvatars = heroNames.map((name) => renderAvatar(name, 'hero', '', name)).join('')
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
const topicCards = report.topics
@@ -480,7 +588,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
(item, index) => `<div class="rank tm-fragment tm-ranking-item">
${renderAvatar(item.sender, 'ranking')}
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
<span>${item.count} 条 · ${item.durationSec} 秒</span>
<span>${item.count} 条 · ${Math.round(item.durationSec)} 秒</span>
</div>`
)
.join('')
@@ -656,7 +764,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
return addReportCsp(injectReportTemplateFragmentContract(html), resolvedTemplate.source === 'builtin')
return addReportCsp(
injectReportTemplateFragmentContract(html),
resolvedTemplate.source === 'builtin'
)
}
const renderReportSnapshotHtml = async (
@@ -679,7 +790,10 @@ const renderReportSnapshotHtml = async (
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
return addReportCsp(html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''), resolvedTemplate.source === 'builtin')
return addReportCsp(
html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''),
resolvedTemplate.source === 'builtin'
)
}
export const extractGroupReportRenderSnapshot = async (
@@ -1021,15 +1135,10 @@ export const exportGroupReport = async (
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(
htmlPath,
pngPath,
request.templateId,
{
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
...resolvedTemplate.definition,
maxCaptureHeight: resolvedTemplate.captureMaxHeight
}
)
})
const pngEndedAt = new Date()
return {
success: true,
@@ -1074,15 +1183,10 @@ export const exportGroupReportSnapshot = async (
await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date()
const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage(
htmlPath,
pngPath,
request.templateId,
{
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
...resolvedTemplate.definition,
maxCaptureHeight: resolvedTemplate.captureMaxHeight
}
)
})
const pngEndedAt = new Date()
return {
success: true,
+423 -66
View File
@@ -1,8 +1,10 @@
import crypto from 'crypto'
import http, { IncomingMessage, ServerResponse, Server } from 'http'
import { app } from 'electron'
import {
isReady,
listContacts,
listContactsAsync,
listMessages,
getGroupSnapshot,
listRecentChat,
@@ -27,6 +29,11 @@ import { safeError, safeLog, safeWarn } from './safe-log'
import { apiTokenStore } from './api-token-store'
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
import { LocalQueryApiService } from './services/local-query-api-service'
import { automationRuleStore, AutomationRulePersistenceError } from './services/automation-rule-store'
import { automationExecutionLogService } from './services/automation-execution-log-service'
import { groupExitMonitorService } from './services/group-exit-monitor-service'
import { LocalAgentApiError, LocalAgentApiService } from './services/local-agent-api-service'
import type { GroupStatsService } from './services/group-stats-service'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
export const DEFAULT_HTTP_PORT = 6131
@@ -44,6 +51,11 @@ interface RouteContext {
body?: unknown
}
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'DELETE'
type RouteHandler = ((ctx: RouteContext) => void | Promise<void>) & {
allowedMethods: readonly HttpMethod[]
}
export interface HttpServerOptions {
tokenProvider?: () => string | null
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
@@ -53,21 +65,56 @@ export interface HttpServerOptions {
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
scheduledReportPlatform?: NodeJS.Platform
queryApiService?: LocalQueryApiService
agentApiService?: LocalAgentApiService
groupStatsService?: Pick<GroupStatsService, 'getMemberStats'>
appVersionProvider?: () => string
}
let configuredQueryApiService: LocalQueryApiService | undefined
let configuredGroupStatsService: Pick<GroupStatsService, 'getMemberStats'> | undefined
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
configuredQueryApiService = service
}
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
export function setLocalGroupStatsService(
service: Pick<GroupStatsService, 'getMemberStats'> | undefined
): void {
configuredGroupStatsService = service
}
const MAX_JSON_BODY_BYTES = 1024 * 1024
class RequestBodyTooLargeError extends Error {
constructor() {
super('Request body exceeds the maximum size')
this.name = 'RequestBodyTooLargeError'
}
}
function withMethods(
methods: readonly HttpMethod[],
handler: (ctx: RouteContext) => void | Promise<void>
): RouteHandler {
return Object.assign(handler, { allowedMethods: methods })
}
function sendMethodNotAllowed(res: ServerResponse, methods: readonly HttpMethod[]): void {
res.setHeader('Allow', methods.join(', '))
sendError(res, 405, `请求方法不受支持;允许的方法:${methods.join(', ')}`)
}
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
const body = JSON.stringify(payload, null, 2)
const requestId = String(res.getHeader('X-Request-Id') || '')
let responsePayload = payload
if (status >= 400 && payload && typeof payload === 'object' && !('requestId' in payload)) {
responsePayload = { ...(payload as Record<string, unknown>), requestId }
}
const body = JSON.stringify(responsePayload, null, 2)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store'
'Cache-Control': 'no-store',
...(requestId ? { 'X-Request-Id': requestId } : {})
})
res.end(body)
}
@@ -90,8 +137,8 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
if (!isAllowedCorsOrigin(origin)) return false
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id')
return true
}
@@ -111,6 +158,20 @@ function sendUnauthorized(res: ServerResponse): void {
})
}
function sendAgentHttpError(
res: ServerResponse,
status: number,
code: string,
message: string,
details?: unknown
): void {
const requestId = String(res.getHeader('X-Request-Id') || '')
sendJson(res, status, {
error: { code, message, ...(details !== undefined ? { details } : {}) },
requestId
})
}
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
}
@@ -120,7 +181,8 @@ function sendBinary(res: ServerResponse, status: number, result: HttpImageResult
'Content-Type': result.mimeType,
'Content-Length': result.buffer.length,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
'X-Content-Type-Options': 'nosniff',
'X-Request-Id': String(res.getHeader('X-Request-Id') || '')
})
res.end(result.buffer)
}
@@ -135,10 +197,26 @@ function sanitizeChatlogMessage(message: Record<string, unknown>): Record<string
return { ...message, contentData: safeContentData }
}
function readBody(req: IncomingMessage): Promise<string> {
function readBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<string> {
return new Promise((resolve, reject) => {
const contentLength = Number(req.headers['content-length'])
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
req.pause()
reject(new RequestBodyTooLargeError())
return
}
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
let size = 0
req.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > maxBytes) {
chunks.length = 0
req.pause()
reject(new RequestBodyTooLargeError())
return
}
chunks.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
req.on('error', reject)
})
@@ -207,18 +285,26 @@ function parseNumeric(value: string | null, fallback: number): number {
return Number.isFinite(n) ? n : fallback
}
function getApplicationVersion(): string {
try {
return typeof app.getVersion === 'function' ? app.getVersion() : 'unknown'
} catch {
return 'unknown'
}
}
const routes: Record<string, RouteHandler> = {
'/api/v1/health': ({ res }) => {
'/api/v1/health': withMethods(['GET'], ({ res }) => {
sendJson(res, 200, {
ok: true,
ready: isReady(),
service: 'TraceMemo Reader',
version: '1.0.0',
version: getApplicationVersion(),
timestamp: new Date().toISOString()
})
},
}),
'/api/v1/current_time': ({ res }) => {
'/api/v1/current_time': withMethods(['GET'], ({ res }) => {
const now = new Date()
sendJson(res, 200, {
time: now.toISOString(),
@@ -228,23 +314,28 @@ const routes: Record<string, RouteHandler> = {
now.getDate()
).padStart(2, '0')}`
})
},
}),
'/api/v1/contact': ({ res, url }) => {
'/api/v1/contact': withMethods(['GET'], async ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const filter = url.searchParams.get('filter') || undefined
const type = url.searchParams.get('type') || undefined
let contacts = listContacts(filter)
// Use the hydrated data source: on macOS the session cache only carries raw
// ids until display names / contact identities are hydrated, so the sync
// `listContacts` would miss nickname and remark matches (Issue #51).
let contacts = await listContactsAsync(filter)
if (type === 'user' || type === 'group') {
contacts = contacts.filter((c) => c.type === type)
}
sendJson(res, 200, { count: contacts.length, contacts })
},
}),
'/api/v1/chatroom': ({ res, url }) => {
'/api/v1/chatroom': withMethods(['GET'], async ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const keyword = url.searchParams.get('keyword') || ''
let groups = listContacts().filter((c) => c.type === 'group')
// Same hydration requirement as /api/v1/contact: group display names are
// exactly the fields that stay un-hydrated on macOS.
let groups = (await listContactsAsync()).filter((c) => c.type === 'group')
if (keyword) {
const lower = keyword.toLowerCase()
groups = groups.filter(
@@ -254,16 +345,16 @@ const routes: Record<string, RouteHandler> = {
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
},
}),
'/api/v1/recent_chat': ({ res, url }) => {
'/api/v1/recent_chat': withMethods(['GET'], ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const limit = parseNumeric(url.searchParams.get('limit'), 50)
const items = listRecentChat(limit)
sendJson(res, 200, { count: items.length, items })
},
}),
'/api/v1/chatlog': ({ res, url }) => {
'/api/v1/chatlog': withMethods(['GET'], ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const talker = url.searchParams.get('talker')
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
@@ -301,27 +392,27 @@ const routes: Record<string, RouteHandler> = {
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
)
})
},
}),
'/api/v1/group_snapshot': ({ res, url }) => {
'/api/v1/group_snapshot': withMethods(['GET'], ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const md5 = url.searchParams.get('md5')
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
const snapshot = getGroupSnapshot(md5)
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
sendJson(res, 200, snapshot)
},
}),
'/api/v1/resolve': ({ res, url }) => {
'/api/v1/resolve': withMethods(['GET'], ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const q = url.searchParams.get('q')
if (!q) return sendError(res, 400, '缺少必要参数 q')
const contact = resolveMd5(q)
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
sendJson(res, 200, contact)
},
}),
'/api/v1/report': async ({ req, res, body }) => {
'/api/v1/report': withMethods(['POST'], async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
if (typeof body !== 'string' || !body.trim()) {
@@ -348,9 +439,9 @@ const routes: Record<string, RouteHandler> = {
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
},
}),
'/api/v1/agent/group-report': async ({ req, res, body }) => {
'/api/v1/agent/group-report': withMethods(['POST'], async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
@@ -364,9 +455,9 @@ const routes: Record<string, RouteHandler> = {
range: request.range
})
sendJson(res, result.success ? 200 : 400, result)
},
}),
'/api/v1/agent/status': ({ res }) => {
'/api/v1/agent/status': withMethods(['GET'], ({ res }) => {
const status = agentHubService.getStatus()
sendJson(res, 200, {
ok: status.hub === 'online' && status.connector === 'online',
@@ -376,9 +467,9 @@ const routes: Record<string, RouteHandler> = {
databaseReady: status.databaseReady,
accountId: status.accountId
})
},
}),
'/api/v1/agent/send': async ({ req, res, body }) => {
'/api/v1/agent/send': withMethods(['POST'], async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let request: { to?: string; text?: string; media_url?: string }
try {
@@ -392,7 +483,7 @@ const routes: Record<string, RouteHandler> = {
mediaUrl: request.media_url
})
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
}
})
}
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
@@ -442,18 +533,18 @@ function createScheduledReportRoute(
api: ScheduledReportApiService
): RouteHandler | undefined {
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
return async ({ req, res }) => {
return withMethods(['GET'], async ({ req, res }) => {
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
try {
sendJson(res, 200, { capability: await api.getCapability() })
} catch (error) {
sendScheduledError(res, error)
}
}
})
}
if (pathname === SCHEDULED_REPORTS_ROUTE) {
return async ({ req, res, body }) => {
return withMethods(['GET', 'POST'], async ({ req, res, body }) => {
try {
if (req.method === 'GET') {
const tasks = await api.list()
@@ -469,7 +560,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
}
})
}
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
@@ -482,7 +573,7 @@ function createScheduledReportRoute(
} catch {
return undefined
}
return async ({ req, res }) => {
return withMethods(['POST'], async ({ req, res }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
try {
const execution = await api.retrySend(executionId)
@@ -490,7 +581,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
}
})
}
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
@@ -504,8 +595,15 @@ function createScheduledReportRoute(
return undefined
}
const action = segments[1]
if (action && !['enable', 'disable', 'run', 'executions'].includes(action)) return undefined
return async ({ req, res, body }) => {
const methods: HttpMethod[] = !action
? ['GET', 'PATCH', 'DELETE']
: action === 'executions'
? ['GET']
: ['POST']
return withMethods(methods, async ({ req, res, body }) => {
try {
if (!action && req.method === 'GET') {
sendJson(res, 200, { task: await api.get(taskId) })
@@ -545,7 +643,7 @@ function createScheduledReportRoute(
} catch (error) {
sendScheduledError(res, error)
}
}
})
}
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
@@ -560,42 +658,46 @@ function queryStatusCode(status: string): number {
return 400
}
function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined {
return async ({ req, res, body }) => {
const pathname = new URL(req.url || '/', 'http://localhost').pathname
function createQueryRoute(pathname: string, api: LocalQueryApiService): RouteHandler | undefined {
if (pathname === '/api/v1/query/capabilities') {
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
return withMethods(['GET'], ({ res }) => {
return sendJson(res, 200, api.capabilities())
})
}
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let payload: any
type QueryOperationResult =
| Awaited<ReturnType<LocalQueryApiService['messages']>>
| Awaited<ReturnType<LocalQueryApiService['search']>>
| Awaited<ReturnType<LocalQueryApiService['context']>>
| Awaited<ReturnType<LocalQueryApiService['overview']>>
const operations: Record<string, (payload: unknown) => Promise<QueryOperationResult>> = {
'/api/v1/query/messages': (payload) =>
api.messages(payload as Parameters<LocalQueryApiService['messages']>[0]),
'/api/v1/query/search': (payload) =>
api.search(payload as Parameters<LocalQueryApiService['search']>[0]),
'/api/v1/query/message-context': (payload) =>
api.context(payload as Parameters<LocalQueryApiService['context']>[0]),
'/api/v1/query/conversation-overview': (payload) =>
api.overview(payload as Parameters<LocalQueryApiService['overview']>[0])
}
const operation = operations[pathname]
if (!operation) return undefined
return withMethods(['POST'], async ({ res, body }) => {
let payload: unknown
try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') }
if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request')
try {
const result = pathname === '/api/v1/query/messages'
? await api.messages(payload)
: pathname === '/api/v1/query/search'
? await api.search(payload)
: pathname === '/api/v1/query/message-context'
? await api.context(payload)
: pathname === '/api/v1/query/conversation-overview'
? await api.overview(payload)
: undefined
if (!result) return sendError(res, 404, `端点不存在: ${pathname}`)
const result = await operation(payload)
return sendJson(res, queryStatusCode(result.status), result)
} catch (error) {
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
}
}
})
}
function createMediaRoute(
mediaProvider: (messageId: string) => Promise<HttpImageResult>
): RouteHandler {
return async ({ req, res, url }) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return sendError(res, 405, '需要 GET 请求')
}
return withMethods(['GET', 'HEAD'], async ({ req, res, url }) => {
const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length)
let messageId: string
try {
@@ -634,7 +736,218 @@ function createMediaRoute(
safeError('[HttpServer] media request failed:', error)
return sendError(res, 500, '图片读取失败')
}
})
}
function createAgentApiService(options: HttpServerOptions): LocalAgentApiService {
const groupStats = options.groupStatsService || configuredGroupStatsService
return options.agentApiService || new LocalAgentApiService({
automationRuleStore,
automationExecutionLogService,
listContacts: listContactsAsync,
isDatabaseReady: isReady,
getVersion: options.appVersionProvider || getApplicationVersion,
getPersonalWechatCapability: () =>
personalWechatCapabilityService.getPersonalWechatSendCapability(),
getAgentHubStatus: () => agentHubService.getStatus(),
getGroupExitMonitorState: () => {
const state = groupExitMonitorService.getState()
return { ...state, monitoredRoomIds: state.monitoredRoomIds || [] }
},
configureGroupExitMonitor: (configuration) =>
groupExitMonitorService.configure(configuration),
setGroupExitMonitorRoomIds: (roomIds) => groupExitMonitorService.setMonitoredRoomIds(roomIds),
setGroupExitMonitorEnabled: (enabled) => groupExitMonitorService.setEnabled(enabled),
listGroupExitMonitorEvents: (query) => groupExitMonitorService.listEvents(query),
...(groupStats ? { getGroupMemberStats: (query) => groupStats.getMemberStats(query) } : {})
})
}
function sendAgentApiError(res: ServerResponse, error: unknown): void {
const requestId = String(res.getHeader('X-Request-Id') || '')
if (error instanceof LocalAgentApiError) {
sendAgentHttpError(res, error.status, error.code, error.message, error.details)
return
}
if (error instanceof AutomationRulePersistenceError) {
sendAgentHttpError(res, 500, 'PERSISTENCE_FAILED', '自动化规则未能保存到本地')
return
}
safeError(`[HttpServer requestId=${requestId}] Agent API request failed:`, error)
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
}
async function handleAgentApi<T>(
res: ServerResponse,
operation: () => Promise<T> | T,
respond: (value: T) => void
): Promise<void> {
try {
respond(await operation())
} catch (error) {
sendAgentApiError(res, error)
}
}
function parseAgentJson(body: unknown): unknown {
if (typeof body !== 'string' || !body.trim()) {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体不能为空')
}
try {
return JSON.parse(body)
} catch {
throw new LocalAgentApiError(400, 'INVALID_ARGUMENT', '请求体 JSON 格式无效')
}
}
function createAgentApiRoute(
pathname: string,
api: LocalAgentApiService
): RouteHandler | undefined {
if (pathname === '/api/v1/capabilities') {
return withMethods(['GET'], ({ res }) =>
void handleAgentApi(res, () => api.getCapabilities(), (capabilities) =>
sendJson(res, 200, capabilities)
)
)
}
const groupExitMonitorPath = '/api/v1/monitors/group-exits'
if (pathname === groupExitMonitorPath) {
return withMethods(['GET', 'PATCH'], async ({ req, res, body }) => {
if (req.method === 'GET') {
await handleAgentApi(res, () => api.getGroupExitMonitorState(), (state) =>
sendJson(res, 200, state)
)
return
}
await handleAgentApi(res, () => api.updateGroupExitMonitor(parseAgentJson(body)), (state) =>
sendJson(res, 200, state)
)
})
}
if (pathname === `${groupExitMonitorPath}/events`) {
return withMethods(['GET'], ({ res, url }) =>
void handleAgentApi(res, () => api.listGroupExitMonitorEvents(url.searchParams), (result) =>
sendJson(res, 200, result)
)
)
}
const groupStatsPrefix = '/api/v1/groups/'
if (pathname.startsWith(groupStatsPrefix)) {
const segments = pathname.slice(groupStatsPrefix.length).split('/')
if (segments.length === 2 && segments[1] === 'member-stats' && segments[0]) {
let conversationId: string
try {
conversationId = decodeURIComponent(segments[0])
} catch {
return undefined
}
return withMethods(['GET'], ({ res, url }) =>
void handleAgentApi(
res,
() => api.getGroupMemberStats(conversationId, url.searchParams),
(result) => sendJson(res, 200, result)
)
)
}
}
const automationCollection = '/api/v1/automations'
if (pathname === automationCollection) {
return withMethods(['GET', 'POST'], async ({ req, res, url, body }) => {
if (req.method === 'GET') {
await handleAgentApi(
res,
() => api.listAutomations({
type: url.searchParams.get('type'),
enabled: url.searchParams.get('enabled')
}),
(rules) => sendJson(res, 200, { count: rules.length, rules })
)
return
}
await handleAgentApi(res, () => api.createAutomation(parseAgentJson(body)), (rule) =>
sendJson(res, 201, { created: true, rule })
)
})
}
if (pathname === `${automationCollection}/validate`) {
return withMethods(['POST'], async ({ res, body }) => {
await handleAgentApi(res, () => api.validateAutomation(parseAgentJson(body)), (result) =>
sendJson(res, 200, result)
)
})
}
if (pathname === `${automationCollection}/executions`) {
return withMethods(['GET'], ({ res, url }) =>
void handleAgentApi(res, () => api.listExecutions(url.searchParams), (result) =>
sendJson(res, 200, result)
)
)
}
const prefix = `${automationCollection}/`
if (!pathname.startsWith(prefix)) return undefined
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
if (segments.length < 1 || segments.length > 2) return undefined
let id: string
try {
id = decodeURIComponent(segments[0])
} catch {
return undefined
}
if (!id || id.includes('/') || id.includes('\\')) return undefined
const action = segments[1]
if (action && action !== 'enable' && action !== 'disable') return undefined
if (action) {
return withMethods(['POST'], ({ res }) =>
void handleAgentApi(res, () => api.setAutomationEnabled(id, action === 'enable'), (rule) =>
sendJson(res, 200, { updated: true, rule })
)
)
}
return withMethods(['GET', 'PATCH', 'DELETE'], async ({ req, res, body }) => {
if (req.method === 'GET') {
await handleAgentApi(res, () => api.getAutomation(id), (rule) =>
sendJson(res, 200, { rule })
)
return
}
if (req.method === 'PATCH') {
await handleAgentApi(
res,
() => api.updateAutomation(id, parseAgentJson(body)),
(rule) => sendJson(res, 200, { updated: true, rule })
)
return
}
await handleAgentApi(res, () => api.deleteAutomation(id), (result) =>
sendJson(res, 200, { deleted: true, ...result })
)
})
}
function requestIdFor(req: IncomingMessage): string {
const incoming = req.headers['x-request-id']
return typeof incoming === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(incoming)
? incoming
: crypto.randomUUID()
}
function isAgentApiPath(pathname: string): boolean {
return pathname === '/api/v1/capabilities' ||
pathname === '/api/v1/monitors/group-exits' ||
pathname.startsWith('/api/v1/monitors/group-exits/') ||
pathname.startsWith('/api/v1/groups/') ||
pathname === '/api/v1/automations' ||
pathname.startsWith('/api/v1/automations/')
}
export function startHttpServer(
@@ -646,11 +959,19 @@ export function startHttpServer(
const mediaProvider = options.mediaProvider || readImageMedia
const scheduledReportApi = createScheduledReportApi(options)
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
const agentApi = createAgentApiService(options)
return new Promise((resolve, reject) => {
const server: Server = http.createServer(async (req, res) => {
const requestId = requestIdFor(req)
res.setHeader('X-Request-Id', requestId)
let agentApiRequest = false
try {
const url = new URL(req.url || '/', `http://${host}:${port}`)
agentApiRequest = isAgentApiPath(url.pathname)
if (!applyCorsHeaders(req, res)) {
if (agentApiRequest) {
return sendAgentHttpError(res, 403, 'FORBIDDEN', 'Origin 不允许访问本地 API')
}
return sendError(res, 403, 'Origin 不允许访问本地 API')
}
if (req.method === 'OPTIONS') {
@@ -659,15 +980,37 @@ export function startHttpServer(
}
const handler =
routes[url.pathname] ||
createAgentApiRoute(url.pathname, agentApi) ||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX)
? createQueryRoute(url.pathname, queryApi)
: undefined) ||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
? createMediaRoute(mediaProvider)
: undefined)
if (!handler) {
if (agentApiRequest) {
return sendAgentHttpError(res, 404, 'NOT_FOUND', `端点不存在: ${url.pathname}`)
}
return sendError(res, 404, `端点不存在: ${url.pathname}`)
}
if (!handler.allowedMethods.includes(req.method as HttpMethod)) {
if (agentApiRequest) {
res.setHeader('Allow', handler.allowedMethods.join(', '))
return sendAgentHttpError(
res,
405,
'METHOD_NOT_ALLOWED',
`请求方法不受支持;允许的方法:${handler.allowedMethods.join(', ')}`,
{ allowedMethods: handler.allowedMethods }
)
}
return sendMethodNotAllowed(res, handler.allowedMethods)
}
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
if (agentApiRequest) {
return sendAgentHttpError(res, 401, 'UNAUTHORIZED', 'Valid API token required')
}
return sendUnauthorized(res)
}
let body: string | undefined
@@ -677,8 +1020,22 @@ export function startHttpServer(
const ctx: RouteContext = { req, res, url, body }
await handler(ctx)
} catch (error) {
safeError('[HttpServer] 请求处理失败:', error)
if (error instanceof RequestBodyTooLargeError) {
res.setHeader('Connection', 'close')
res.shouldKeepAlive = false
if (agentApiRequest) {
sendAgentHttpError(res, 413, 'PAYLOAD_TOO_LARGE', '请求体不能超过 1 MiB')
return
}
sendError(res, 413, '请求体不能超过 1 MiB')
return
}
safeError(`[HttpServer requestId=${requestId}] 请求处理失败:`, error)
if (!res.headersSent) {
if (agentApiRequest) {
sendAgentHttpError(res, 500, 'INTERNAL_ERROR', 'Agent API 请求失败')
return
}
sendError(res, 500, error instanceof Error ? error.message : String(error))
}
}
+5 -1
View File
@@ -114,7 +114,11 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa
)
}
function resolveFfmpegExecutable(): string {
/**
* 解析可用的 ffmpeg 可执行文件。除图片解密自身使用外,也供 System OCR 的
* 图片归一化(GIF/BMP/WebP/TIFF → PNG)复用,避免重复一套路径探测逻辑。
*/
export function resolveFfmpegExecutable(): string {
for (const candidate of getFfmpegCandidates()) {
const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\')
if (pathLike) {
+704 -136
View File
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -134,6 +134,15 @@ export function parseXkeyHelperOutput(output: string): DatabaseKeyResult {
return mapXkeyHelperFailure(rawError)
}
export const SIP_ENABLED_ERROR =
'macOS 系统完整性保护(SIP)已开启,无法自动获取数据库密钥。请先关闭 SIP,或改用手动粘贴。'
export function parseSipEnabled(statusOutput: string): boolean {
const status = statusOutput.match(/status:\s*([a-z]+)/i)?.[1]?.toLowerCase()
if (status) return status === 'enabled'
return statusOutput.toLowerCase().includes('enabled')
}
export class KeyServiceMac {
private getMacKeyRuntimeDir(): string {
return path.join(app.getPath('userData'), 'key-runtime')
@@ -306,7 +315,7 @@ export class KeyServiceMac {
private async isSipEnabled(): Promise<boolean> {
try {
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
return stdout.toLowerCase().includes('enabled')
return parseSipEnabled(stdout)
} catch {
return false
}
@@ -346,10 +355,7 @@ export class KeyServiceMac {
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
}
if (await this.isSipEnabled()) {
return {
success: false,
error: '当前系统还未完成连接环境准备,请按页面提示完成设置。'
}
return { success: false, code: 'SIP_ENABLED', error: SIP_ENABLED_ERROR }
}
try {
+113 -11
View File
@@ -1,8 +1,10 @@
import { monitorEventLoopDelay } from 'perf_hooks'
import * as chat from '../services/chat-service'
import type {
KnowledgeImageOcrState,
KnowledgeAttachmentMetadata,
KnowledgeEvidence,
KnowledgeMemberStatsResult,
KnowledgeMessageKind,
KnowledgePassProgress,
KnowledgeRuntimeState,
@@ -24,6 +26,7 @@ import {
emptyKnowledgeSearchTimings
} from '../../shared/knowledge'
import { KnowledgeService } from './knowledge-service'
import { sourceMessageId } from './message-identity'
import {
voiceAccountIdentity,
voiceMessageIdentity
@@ -118,11 +121,6 @@ function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]):
)
}
function sourceMessageId(message: chat.FormattedMessage): string {
if (message.localId) return `local:${message.localId}`
if (message.id) return String(message.id)
return `${message.createTime || 0}:${message.serverId || message.content}`
}
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
if (message.voiceTranscript || message.type === '语音') return 'voice'
@@ -130,6 +128,16 @@ function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
return message.exportMediaType
}
if (message.exportMediaType === 'file') return 'file'
// 索引路径上 `exportMediaType` **不会被赋值**(只有 export-service 会设它),
// 所以图片/视频/表情包必须从 contentData.type 判定,否则图片会静默落成 'other',
// 进而让"图片文字索引"的 Evidence 丢掉真正的来源类型。
if (
message.contentData?.type === 'image' ||
message.contentData?.type === 'video' ||
message.contentData?.type === 'sticker'
) {
return message.contentData.type
}
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file'
@@ -201,12 +209,16 @@ function toSourceMessage(
accountId: string,
conversationId: string,
message: chat.FormattedMessage,
transcriptOverride?: string
transcriptOverride?: string,
imageOcr?: { state: KnowledgeImageOcrState; text: string }
): KnowledgeSourceMessage | null {
if (!message.createTime) return null
const extracted = sourceTextAndAttachment(message)
const voiceTranscript = transcriptOverride?.trim() || message.voiceTranscript?.trim() || undefined
if (!extracted.text && !extracted.attachment && !voiceTranscript) return null
// 图片 OCR 文本走与语音转写完全相同的派生通道:有文本才入库,
// 没有文字的图片(表情包/风景)不会污染索引。
const imageOcrText = imageOcr?.text?.trim() || undefined
if (!extracted.text && !extracted.attachment && !voiceTranscript && !imageOcrText) return null
return {
accountId,
conversationId,
@@ -218,7 +230,9 @@ function toSourceMessage(
kind: sourceKind(message),
text: extracted.text,
attachment: extracted.attachment,
voiceTranscript
voiceTranscript,
...(imageOcrText ? { imageOcrText } : {}),
...(imageOcrText && imageOcr?.state ? { imageOcrState: imageOcr.state } : {})
}
}
@@ -256,6 +270,14 @@ export class KnowledgeSearchService {
private interactiveIdleResolve: (() => void) | null = null
private wcdbQueueMsTotal = 0
private wcdbExecutionMsTotal = 0
/**
* 图片 OCR 文本解析器(由 main 注入)。
*
* 与语音同构:派生文本在**主进程**解析后贴到消息上,派生库不进 worker。
*/
private imageOcrResolver:
| ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
| undefined
private voiceTranscriptResolver:
| ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot)
| undefined
@@ -372,6 +394,15 @@ export class KnowledgeSearchService {
this.voiceTranscriptResolver = resolver
}
/** 注入图片 OCR 文本解析器(本地 System OCR 的派生结果)。 */
setImageOcrResolver(
resolver:
| ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
| undefined
): void {
this.imageOcrResolver = resolver
}
/**
* A successful recognition updates its source conversation. Consecutive
* updates for the same conversation are coalesced because a complete
@@ -468,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` 组成。
*/
@@ -1051,7 +1113,7 @@ export class KnowledgeSearchService {
lane: WcdbReadLane = 'interactive'
): ReturnType<typeof chat.listMessagesAsync> {
return this.enqueueWcdbRead(
() => chat.listMessagesAsync(conversationId, startTime, endTime),
() => chat.listMessagesAsync(conversationId, startTime, endTime, undefined, undefined, 'knowledge'),
lane
)
}
@@ -1074,8 +1136,16 @@ export class KnowledgeSearchService {
const reference = this.voiceReferenceFromMessage(message)
const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined
const hydrated = this.withVoiceTranscript(message)
const source = toSourceMessage(accountId, conversationId, hydrated, transcriptOverride)
if (!source || source.kind !== 'voice') return source
const imageOcr = this.imageOcrResolver?.(conversationId, sourceMessageId(message))
const source = toSourceMessage(
accountId,
conversationId,
hydrated,
transcriptOverride,
imageOcr
)
if (!source) return source
if (source.kind !== 'image' && source.kind !== 'voice') return source
return {
...source,
voiceTranscriptState:
@@ -1105,6 +1175,38 @@ export class KnowledgeSearchService {
}
}
/**
* 某个会话的图片 OCR 处理完成 → 重建该会话的索引。
*
* 与"语音转写完成后单会话重索引"完全同构:整会话重读 + completeSnapshot 重建,
* 让 OCR 派生文本进入 chunks/FTS,从而可被 search_messages 检索。
* 原图片消息仍然是 authoritative source —— 这里只是让它多了一段派生文本,
* 不产生任何"OCR 消息"。
*/
async indexImageOcr(conversationId: string): Promise<void> {
if (!chat.isReady()) return
const accountId = this.currentAccountId()
if (!accountId) return
const activeIndex = this.indexing.get(accountId)
if (activeIndex) await activeIndex
const contacts = await this.listContacts()
const contact = contacts.find((item) => item.md5 === conversationId)
if (!contact) return
const messages = await this.listMessages(contact.md5, undefined, undefined, 'background')
const sourceMessages = messages
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
await this.service.index({
accountId,
conversations: [
{ conversationId: contact.md5, completeSnapshot: true, messages: sourceMessages }
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
})
await this.refreshStatus(accountId)
}
private async indexVoiceTranscriptNow(update: VoiceTranscriptUpdate): Promise<void> {
if (!chat.isReady()) return
if (update.state === 'transcribed' && !update.transcript?.trim()) return
+9
View File
@@ -5,6 +5,8 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchResult,
@@ -53,6 +55,13 @@ export class KnowledgeService {
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
}
/** 单个会话内「按发送者聚合」的发言统计(群员统计用)。 */
memberStats(
request: Omit<KnowledgeMemberStatsRequest, 'databaseRoot'>
): Promise<KnowledgeMemberStatsResult> {
return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot })
}
/** 只中止正在跑的索引任务;查询请求不受影响。 */
cancelIndex(): Promise<boolean> {
return this.worker.cancelActiveIndex()
+127 -7
View File
@@ -13,13 +13,18 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeNormalizedMessage,
KnowledgeQuery,
KnowledgeSearchTimings,
KnowledgeSearchResult
} from '../../shared/knowledge'
import { emptyKnowledgeSearchTimings, KNOWLEDGE_SCHEMA_VERSION } from '../../shared/knowledge'
import {
emptyKnowledgeSearchTimings,
KNOWLEDGE_SCHEMA_VERSION,
toEvidenceDisplayText
} from '../../shared/knowledge'
import { chunkConversation } from './chunker'
import { normalizeKnowledgeMessage } from './normalizer'
@@ -313,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)——「索引更新到哪」的权威口径。
*
@@ -761,7 +853,8 @@ export class KnowledgeStore {
evidence: asRows(
this.database
.prepare(
`SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.kind, m.sender_id, m.sender_name
`SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.kind, m.sender_id, m.sender_name,
m.image_ocr_text, m.voice_transcript
FROM knowledge_messages m
WHERE ${clauses.join(' AND ')}
ORDER BY m.create_time DESC
@@ -789,7 +882,22 @@ export class KnowledgeStore {
timestamp: Number(row.create_time),
messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId],
sourceKind: String(row.kind) as KnowledgeEvidence['sourceKind'],
text: String(row.searchable_text),
// 内部前缀(`图片文字:`)绝不能进 Evidence:面向用户与模型的是可读文本,
// 来源信息由下面的结构化字段表达。
text: toEvidenceDisplayText(String(row.searchable_text)),
...(row.image_ocr_text ? { imageOcrText: String(row.image_ocr_text) } : {}),
/*
* 来源标记按"这条消息带什么派生内容"判定,与 `sourceKind` 正交:
* `image_ocr` = 靠图片里的文字命中,`voice_transcript` = 靠语音转写命中。
*
* 两者都有时以图片 OCR 为先 —— 图片消息不会同时带语音转写,这里只是取确定值,
* 实际不会出现需要二选一的数据。
*/
...(row.image_ocr_text
? { derivedSource: 'image_ocr' as const }
: String(row.voice_transcript || '').trim()
? { derivedSource: 'voice_transcript' as const }
: {}),
score: String(row.kind) === 'system' ? 1 : 0
}
}
@@ -899,6 +1007,7 @@ export class KnowledgeStore {
attachment_json TEXT,
voice_transcript TEXT,
voice_transcript_state TEXT,
image_ocr_text TEXT,
PRIMARY KEY (conversation_id, message_id)
) STRICT;
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
@@ -957,6 +1066,14 @@ export class KnowledgeStore {
if (!messageColumns.has('voice_transcript_state')) {
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN voice_transcript_state TEXT')
}
// 图片 OCR 派生文本单独留一列(不只是埋进 searchable_text)。
//
// 为什么必须落列而不是从 searchable_text 里截字符串:Evidence 需要回答
// "这条结果是不是来自图片里的文字",并按此给出来源标记与 OCR 片段。
// 靠解析前缀来判来源,一旦前缀格式调整就会静默失效。
if (!messageColumns.has('image_ocr_text')) {
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN image_ocr_text TEXT')
}
this.writeMetaIfMissing('schema_version', String(KNOWLEDGE_SCHEMA_VERSION))
const storedAccount = this.readMeta('account_id')
if (storedAccount && storedAccount !== this.accountId) {
@@ -1137,8 +1254,9 @@ export class KnowledgeStore {
const upsert = this.database.prepare(
`INSERT INTO knowledge_messages (
account_id, conversation_id, message_id, create_time, content_hash, searchable_text,
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state,
image_ocr_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id, message_id) DO UPDATE SET
create_time = excluded.create_time,
content_hash = excluded.content_hash,
@@ -1148,7 +1266,8 @@ export class KnowledgeStore {
sender_name = excluded.sender_name,
attachment_json = excluded.attachment_json,
voice_transcript = excluded.voice_transcript,
voice_transcript_state = excluded.voice_transcript_state`
voice_transcript_state = excluded.voice_transcript_state,
image_ocr_text = excluded.image_ocr_text`
)
for (let index = 0; index < messages.length; index += 1) {
this.assertNotAborted(signal)
@@ -1165,7 +1284,8 @@ export class KnowledgeStore {
message.senderName ?? null,
message.attachment ? encodedJson(message.attachment) : null,
message.voiceTranscript ?? null,
message.voiceTranscriptState ?? null
message.voiceTranscriptState ?? null,
message.imageOcrText ?? null
)
if (index % YIELD_EVERY === 0) {
onProgress(index + 1, 0)
@@ -6,6 +6,8 @@ import type {
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchResult,
@@ -19,6 +21,7 @@ type WorkerResult =
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| KnowledgeMemberStatsResult
| { marks: Record<string, number> }
| { removed: true }
type PendingRequest = {
@@ -76,6 +79,11 @@ export class KnowledgeWorkerHost {
)
}
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
}
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
async cancelActiveIndex(): Promise<boolean> {
const target = this.activeIndexRequestId
+38
View File
@@ -1,6 +1,8 @@
import type {
KnowledgeCapacityPreflightRequest,
KnowledgeIndexRequest,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeStatusRequest,
@@ -187,6 +189,38 @@ async function handleHighWater(
})
}
/**
* 群员统计的按发送者聚合。
*
* 与 `handleSearch` 同样先判库是否存在:**「还没建索引」是正常状态,不是故障**,
* 返回空结果而不是抛错,让上层能稳定地区分「没人发言」与「索引不存在」。
*/
async function handleMemberStats(
request: KnowledgeWorkerRequest,
payload: KnowledgeMemberStatsRequest
): Promise<void> {
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
if (!existsSync(path)) {
const unavailable: KnowledgeMemberStatsResult = {
conversationId: payload.conversationId,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: null,
indexLatestAt: null
}
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return
}
send({
version: 1,
type: 'result',
requestId: request.requestId,
payload: getStore(payload).memberStats(payload)
})
}
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
try {
if (request.type === 'cancel') {
@@ -226,6 +260,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'memberStats') {
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
return
}
if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest)
return
+24
View File
@@ -0,0 +1,24 @@
/**
* 消息身份的**唯一真源**。
*
* 这个规则同时被三处需要:
* - Knowledge 索引写入 `knowledge_messages.message_id`
* - 图片文字索引的 binding(必须与 Knowledge 里的 message_id 完全一致,否则 OCR 文本贴不到消息上)
* - Evidence → 档案跳转的 messageRef
*
* 任何一处各自复制一份,都会在 `local:` 前缀上静默失配(项目里已经有这个坑的历史注释),
* 所以抽成一个模块,谁都不许再抄。
*/
import type * as chat from '../services/chat-service'
/**
* 源消息 → 稳定消息 id。
*
* 降级顺序刻意保守:`localId` 是 WCDB 行内最稳的本地 id;其次用消息自带 id;
* 最后才退化成「时间 + 服务端 id / 内容」的组合(仅在极端缺字段时命中)。
*/
export function sourceMessageId(message: chat.FormattedMessage): string {
if (message.localId) return `local:${message.localId}`
if (message.id) return String(message.id)
return `${message.createTime || 0}:${message.serverId || message.content}`
}
+5
View File
@@ -24,6 +24,10 @@ export function normalizeKnowledgeMessage(
const transcript = compact(source.voiceTranscript)
if (transcript) sections.push(`语音转写:${transcript}`)
// 图片 OCR 文本:与语音同样的"固定前缀"约定,让检索与展示都能识别这是派生内容。
const imageText = compact(source.imageOcrText)
if (imageText) sections.push(`图片文字:${imageText}`)
const attachmentName = compact(source.attachment?.name)
if (attachmentName) {
const label = source.attachment?.kind === 'link' ? '链接' : '附件'
@@ -45,6 +49,7 @@ export function normalizeKnowledgeMessage(
senderId: source.senderId || '',
kind: source.kind,
voiceTranscriptState: source.voiceTranscriptState || '',
imageOcrState: source.imageOcrState || '',
searchableText
})
)
+119 -8
View File
@@ -125,7 +125,10 @@ export type ParsedContent =
export function parseMessageContent(content: string, messageType: number): ParsedContent {
// Voice rows may keep their binary payload outside msgContent, so an empty
// content string is still a valid voice message.
if (messageType === 34) return { type: 'voice' }
if (messageType === 34) {
const duration = parseVoiceDurationSeconds(content)
return duration === undefined ? { type: 'voice' } : { type: 'voice', duration }
}
if (!content || typeof content !== 'string') {
return { type: 'unknown', raw: content || '' }
}
@@ -157,6 +160,33 @@ export function parseMessageContent(content: string, messageType: number): Parse
}
}
/**
* 语音时长藏在解压后的 message_content 里:`<voicemsg ... voicelength="1600" ...>`,单位毫秒。
* Msg_* 表没有 voice_length 列,这是唯一来源。
*
* `<voicemsg>` 上两个极易混淆的属性(真机实测,同一条 1.6 秒语音,2026-09-20):
*
* - `voicelength="1600"` → **毫秒时长**。这条语音微信气泡显示 2"(1.6 秒四舍五入)。
* **要取的是它。**
* - `length="6672"` → **SILK 编码数据的字节数,与时长无关**。
* 已验证:`wcdb_get_voice_data` 取出的 SILK 恰好是 6672 字节,
* 解码后为 51200 字节 PCM(1.6 秒)。误取它会算出 6.672 秒,把 2" 显示成 0:07。
*
* 换算成秒后**刻意保留小数**(1600ms → 1.6):在这里取整会把精度永久丢掉,
* 后面显示层再怎么四舍五入都对不回微信的口径(微信是四舍五入到整秒)。
*
* 注:`<videomsg length="...">` 的 `length` 同理是字节数,不是时长。
*/
function parseVoiceDurationSeconds(content: string): number | undefined {
if (!content || typeof content !== 'string') return undefined
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const rawLength = extractXmlAttribute(decoded, 'voicemsg', 'voicelength')
if (!rawLength) return undefined
const milliseconds = Number(rawLength)
if (!Number.isFinite(milliseconds) || milliseconds <= 0) return undefined
return milliseconds / 1000
}
function parseVideoMessage(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
@@ -181,6 +211,14 @@ function parseSystemMessage(content: string): ParsedContent {
recall
}
}
const templateText = extractSysmsgTemplateText(decoded)
if (templateText) {
return {
type: 'system',
content: templateText,
raw: content
}
}
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
if (delChatroomMemberText) {
return {
@@ -189,16 +227,19 @@ function parseSystemMessage(content: string): ParsedContent {
raw: content
}
}
// <link_list> 里放的是富文本片段(可能含 hidden="1" 的可点击按钮),
// 不是消息正文;先剥掉再走通用提取,避免把按钮文案当成整条系统消息。
const withoutLinkList = stripSysmsgLinkList(decoded)
const plainText =
extractXmlNodeText(decoded, 'plain') ||
extractXmlNodeText(decoded, 'text') ||
extractXmlNodeText(decoded, 'title') ||
extractXmlValue(decoded, 'plain') ||
extractXmlValue(decoded, 'text') ||
extractXmlValue(decoded, 'title') ||
extractXmlNodeText(withoutLinkList, 'plain') ||
extractXmlNodeText(withoutLinkList, 'text') ||
extractXmlNodeText(withoutLinkList, 'title') ||
extractXmlValue(withoutLinkList, 'plain') ||
extractXmlValue(withoutLinkList, 'text') ||
extractXmlValue(withoutLinkList, 'title') ||
''
const normalized = normalizeSystemText(plainText || fallbackSystemText(decoded))
const normalized = normalizeSystemText(plainText || fallbackSystemText(withoutLinkList))
return {
type: 'system',
content: normalized || '[系统消息]',
@@ -838,6 +879,76 @@ function extractDelChatroomMemberText(xml: string): string {
return ''
}
/** 剥掉 <link_list> 区块 —— 其中的文案属于富文本片段,不是消息正文。 */
function stripSysmsgLinkList(xml: string): string {
return String(xml || '').replace(/<link_list\b[\s\S]*?<\/link_list>/gi, ' ')
}
/**
* 微信 4.x 起,部分系统消息改成「模板」格式,正文不再写在 <plain> 里。
*
* 旧格式(正文就在 <plain>,取到即可):
*
* <sysmsg type="delchatroommember"><delchatroommember>
* <plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></plain>
* <link><scene>qrcode</scene><text><![CDATA[撤销]]></text>…</link>
* </delchatroommember></sysmsg>
*
* 新格式(<plain> 变空,正文挪进 <template>,用 $名称$ 引用 <link_list> 里的 link):
*
* <sysmsg type="sysmsgtemplate"><sysmsgtemplate>
* <content_template type="tmpl_type_profilewithrevokeqrcode">
* <plain><![CDATA[]]></plain>
* <template><![CDATA["$adder$"通过扫描你分享的二维码加入群聊 $revoke$]]></template>
* <link_list>
* <link name="adder" type="link_profile">
* <memberlist><member><nickname><![CDATA[成员昵称]]></nickname></member></memberlist>
* </link>
* <link name="revoke" type="link_revoke_qrcode" hidden="1">
* <title><![CDATA[撤销]]></title>
* </link>
* </link_list>
* </content_template>
* </sysmsgtemplate></sysmsg>
*
* 两个要点:
* 1. 正文取自 <template>,其中的 $名称$ 占位符按 <link_list> 的 link name 回填;
* 2. hidden="1" 的 link 在微信里是可点击按钮,纯文本展示时省略其文案。
*
* 漏掉这段会让新格式消息落进通用提取链:<plain> 为空、又没有 <text>,
* 于是取到 <title> —— 也就是那个隐藏按钮的标题,整条系统消息只剩一个按钮名。
*/
function extractSysmsgTemplateText(xml: string): string {
if (!/<sysmsgtemplate\b|<content_template\b/i.test(xml)) return ''
const template = extractXmlNodeText(xml, 'template')
if (!template) return ''
const links = new Map<string, { text: string; hidden: boolean }>()
const linkPattern = /<link\b([^>]*)>([\s\S]*?)<\/link>/gi
let linkMatch: RegExpExecArray | null
while ((linkMatch = linkPattern.exec(xml)) !== null) {
const name = extractXmlValue(linkMatch[1], 'name')
if (!name) continue
links.set(name, {
// title 用于按钮文案,nickname 用于成员展示名,text 作最后兜底。
text:
extractXmlNodeText(linkMatch[2], 'title') ||
extractXmlNodeText(linkMatch[2], 'nickname') ||
extractXmlNodeText(linkMatch[2], 'text') ||
'',
hidden: /hidden\s*=\s*["']1["']/i.test(linkMatch[1])
})
}
const rendered = template.replace(/\$([A-Za-z0-9_]+)\$/g, (_raw, name: string) => {
const link = links.get(name)
return link && !link.hidden ? link.text : ''
})
return normalizeSystemText(rendered)
}
function normalizeMd5(value: unknown): string | undefined {
const md5 = String(value || '')
.trim()
+43 -15
View File
@@ -23,6 +23,45 @@ import {
const aiProvider = new AIProviderService()
/**
* 用真实群成员快照补全消息的显示名与头像。
*
* **头像与名称的门槛刻意不同**:
*
* 此前这里先用 `isInternalName(message.name)` 做整体早退 —— 只有当消息里的名字还是内部标识
* (空 / `wxid_*` / `*@chatroom` / 18+ 位字母数字)时才继续。但 `listMessages` 产出的 `name`
* 优先取 `senderNickname`,在真实群里通常是**已可读的昵称**,于是整条记录被跳过、
* `member.avatar` 永远补不上,导出层只能退化成首字头像;软件内日报没有这个门槛,
* 所以它能显示真实头像。
*
* 现在:**只要 senderId 命中真实群成员就允许补头像**;名称只在解析结果确实是可读名时才采用,
* 避免把调用方已有的昵称降级成空串或内部标识。消息自带的 `img` 始终优先。
*/
export function hydrateGroupMemberIdentity(
messages: Message[],
members: ReadonlyArray<NonNullable<ReturnType<typeof getGroupSnapshot>>['members'][number]>,
memberNameMode: ScheduledReportMemberNameMode
): Message[] {
const index = new Map(
members.map((member) => [
member.wxid,
{ name: resolveMemberName(member, memberNameMode), avatar: member.avatar }
])
)
return messages.map((message) => {
const member = index.get(String(message.senderId || message.name || ''))
if (!member) return message
const shouldFillAvatar = !message.img && Boolean(member.avatar)
const resolvedName = member.name && !isInternalName(member.name) ? member.name : message.name
if (!shouldFillAvatar && resolvedName === message.name) return message
return {
...message,
name: resolvedName,
...(shouldFillAvatar ? { img: member.avatar } : {})
}
})
}
export interface AgentGroupReportRequest {
group: string
range?: SummaryDateRange | 'recent24h'
@@ -101,22 +140,11 @@ export async function generateAgentGroupReport(
const snapshot = getGroupSnapshot(contact.md5)
if (snapshot) {
const members = new Map(
snapshot.members.map((member) => [
member.wxid,
{
name: resolveMemberName(member, request.memberNameMode || 'groupNickname'),
avatar: member.avatar
}
])
messages = hydrateGroupMemberIdentity(
messages,
snapshot.members,
request.memberNameMode || 'groupNickname'
)
messages = messages.map((message) => {
if (!isInternalName(message.name)) return message
const member = members.get(String(message.senderId || message.name || ''))
return member?.name
? { ...message, name: member.name, img: message.img || member.avatar }
: message
})
}
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
@@ -0,0 +1,193 @@
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { randomUUID } from 'node:crypto'
import {
agentHubKindPlaceholder,
type AgentHubConversation,
type AgentHubConversationMessage,
type AgentHubConversationSummary,
type AgentHubMessageKind,
type AgentHubMessageStatus
} from '../../shared/agent-hub-conversation'
/**
* Agent Hub 对话记录存储。
*
* 设计取向:
* - 一个 JSON 文件装全部会话,按「最近活跃」排序,避免为几十个会话开目录;
* - 每个会话最多保留 N 条(默认 500),超出丢最旧的,文件不会无限增长;
* - 会话数也有上限(默认 50),只保留最近活跃的;
* - 原子写(tmp + rename)+ 0600,任何一个环节失败都不能影响真实收发。
*
* 与 `WechatInboundInbox` 的区别:收件箱是**待处理的在途消息**(处理完即删),
* 这里是**供人回看的历史**(按上限长期保留)。
*/
const DEFAULT_MAX_MESSAGES_PER_CONVERSATION = 500
const DEFAULT_MAX_CONVERSATIONS = 50
interface ConversationFile {
version: 1
conversations: AgentHubConversation[]
}
export interface AgentHubConversationStoreOptions {
filePath: () => string
maxMessagesPerConversation?: number
maxConversations?: number
now?: () => number
createId?: () => string
}
export interface AppendInput {
userId: string
accountId?: string
direction: 'in' | 'out'
kind: AgentHubMessageKind
text: string
messageId?: string
status?: AgentHubMessageStatus
errorCode?: string
createdAt?: number
}
export class AgentHubConversationStore {
private readonly options: AgentHubConversationStoreOptions
private readonly maxMessages: number
private readonly maxConversations: number
private cache: AgentHubConversation[] | null = null
constructor(options: AgentHubConversationStoreOptions) {
this.options = options
this.maxMessages = options.maxMessagesPerConversation ?? DEFAULT_MAX_MESSAGES_PER_CONVERSATION
this.maxConversations = options.maxConversations ?? DEFAULT_MAX_CONVERSATIONS
}
/** 追加一条消息,返回受影响会话的摘要与这条消息(供 UI 增量刷新)。 */
append(
input: AppendInput
): { summary: AgentHubConversationSummary; message: AgentHubConversationMessage } | null {
const userId = String(input.userId || '').trim()
if (!userId) return null
const createdAt = input.createdAt ?? this.options.now?.() ?? Date.now()
const message: AgentHubConversationMessage = {
id: this.options.createId?.() ?? randomUUID(),
direction: input.direction,
kind: input.kind,
text: String(input.text ?? ''),
createdAt,
...(input.status ? { status: input.status } : {}),
...(input.errorCode ? { errorCode: input.errorCode } : {}),
...(input.messageId ? { messageId: input.messageId } : {})
}
try {
const conversations = this.load()
const index = conversations.findIndex((item) => item.userId === userId)
if (index >= 0) {
const existing = conversations[index]
const messages = [...existing.messages, message].slice(-this.maxMessages)
conversations[index] = {
...existing,
...(input.accountId ? { accountId: input.accountId } : {}),
lastAt: createdAt,
messages
}
} else {
conversations.push({
userId,
...(input.accountId ? { accountId: input.accountId } : {}),
firstAt: createdAt,
lastAt: createdAt,
messages: [message]
})
}
// 只保留最近活跃的 maxConversations 个会话。
const trimmed = conversations
.sort((left, right) => right.lastAt - left.lastAt)
.slice(0, this.maxConversations)
this.persist(trimmed)
this.cache = trimmed
const summary = this.toSummary(trimmed.find((item) => item.userId === userId) ?? null)
return summary ? { summary, message } : null
} catch (error) {
// 记录历史失败绝不能影响真实收发。
console.warn('[AgentHubConversation] 写入对话记录失败:', error)
return null
}
}
listSummaries(): AgentHubConversationSummary[] {
return this.load()
.slice()
.sort((left, right) => right.lastAt - left.lastAt)
.map((conversation) => this.toSummary(conversation))
.filter((summary): summary is AgentHubConversationSummary => summary !== null)
}
get(userId: string): AgentHubConversation | null {
const normalized = String(userId || '').trim()
if (!normalized) return null
const found = this.load().find((item) => item.userId === normalized)
if (!found) return null
return { ...found, messages: found.messages.map((message) => ({ ...message })) }
}
clear(): void {
this.persist([])
this.cache = []
}
private toSummary(conversation: AgentHubConversation | null): AgentHubConversationSummary | null {
if (!conversation) return null
const last = conversation.messages[conversation.messages.length - 1]
const preview = last ? last.text.trim() || agentHubKindPlaceholder(last.kind) : ''
return {
userId: conversation.userId,
...(conversation.accountId ? { accountId: conversation.accountId } : {}),
firstAt: conversation.firstAt,
lastAt: conversation.lastAt,
messageCount: conversation.messages.length,
lastPreview: preview,
lastDirection: last?.direction ?? 'in'
}
}
private load(): AgentHubConversation[] {
if (this.cache) return this.cache
let parsed: ConversationFile | null = null
try {
parsed = JSON.parse(readFileSync(this.options.filePath(), 'utf8')) as ConversationFile
} catch {
parsed = null
}
const conversations = Array.isArray(parsed?.conversations)
? parsed!.conversations.filter((item): item is AgentHubConversation =>
Boolean(item && typeof item === 'object' && String(item.userId || '').trim())
)
: []
this.cache = conversations
return conversations
}
private persist(conversations: AgentHubConversation[]): void {
const path = this.options.filePath()
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
try {
writeFileSync(
tempPath,
JSON.stringify({ version: 1, conversations } satisfies ConversationFile),
{ encoding: 'utf8', mode: 0o600 }
)
chmodSync(tempPath, 0o600)
renameSync(tempPath, path)
chmodSync(path, 0o600)
} catch (error) {
rmSync(tempPath, { force: true })
throw error
}
}
}
+36 -1
View File
@@ -87,15 +87,50 @@ export function matchGroupMemberChatIntent(text: string): GroupMemberChatIntent
return null
}
/**
* 「列出会话」的**形态标记**:用户在问"有哪些 / 和谁 / 列表 / 最近 N 条",
* 而不是在问"聊了什么内容"。
*/
const RECENT_LIST_SHAPE = /(哪些|哪个|都有谁|都跟谁|和谁|跟谁|是谁|列表|名单|\d{1,2}(条|个|位))/
/** 名单类问题必须落到会话 / 联系人这个对象上。 */
const RECENT_LIST_TARGET = /(聊天|会话|联系人|好友|人|群|消息|窗口)/
/** 「和谁 / 跟谁」问法本身就在问会话对象,不要求额外载体词。 */
const RECENT_PEER_QUESTION = /(和谁|跟谁)/
/** 内容探针:问的是消息里的内容 / 是否提到某事物 —— 必须交给 Query Agent。 */
const RECENT_CONTENT_PROBE =
/(提到|提过|说过|说啥|说什么|说了什么|聊了啥|聊了什么|都聊什么|都说什么|什么话题|聊到|讨论|内容|讲了什么|哪条|哪一句|有没有|是否)/
/**
* "最近有哪些会话"类请求。
*
* 这是**确定性能力**(列出会话),不是消息内容查询 —— Query Agent 无法表达,
* 因此保留为不经过模型的无 LLM 快捷路径。
*
* 判定必须**正向**:只有用户确实在要一份"会话 / 联系人名单"时才算 recent_list。
* 早先的实现只要求「最近」+「消息|会话|聊天」同时出现,于是
* 「最近群里聊的消息里有没有提到报价?」这类**内容查询**会被截走,
* 直接回一串会话名,用户永远得不到答案。
*/
export function matchRecentChatIntent(text: string): number | null {
const normalized = text.replace(/\s+/g, '')
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
if (!normalized.includes('最近')) return null
// 内容探针优先排除:问"有没有提到 X / 谁提过 X / 聊了什么"是在查消息内容,不是要名单。
if (RECENT_CONTENT_PROBE.test(normalized)) return null
/**
* 只有两种形态算"要最近会话列表":
* 1) 「和谁 / 跟谁」问法 —— 它本身就在问会话对象,不需要额外的载体词
* (如「最近和谁聊过」);
* 2) 名单形态 + 会话载体 —— 如「最近有哪些聊天」「最近 5 个会话」「最近3条消息」。
*
* 两种都不满足时交给 Query Agent:形状不像"要名单"的,就是在问内容。
*/
const listLike =
RECENT_PEER_QUESTION.test(normalized) ||
(RECENT_LIST_SHAPE.test(normalized) && RECENT_LIST_TARGET.test(normalized))
if (!listLike) return null
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
return Math.max(1, Math.min(20, limit))
}
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -249,16 +249,33 @@ function selectCoverage(
}
}
/**
* Citation sanitize 的允许集合。
*
* 两个入口都只能引用「Host 侧已分配」的编号,但它们的形状不同:
* - Query Agent:`citationId` 字符串集合(`EvidenceCollector` 分配的 `E1`…`En`);
* - Legacy AI Search:Final Evidence 列表(取其中的 `id`)。
*
* 故意不接受裸 `string`:那会被当成字符序列迭代,静默退化成单字符白名单。
*/
export type CitationAllowList =
| ReadonlyArray<string | Pick<AiSearchFinalEvidence, 'id'>>
| ReadonlySet<string>
/** Do not expose citations that cannot resolve to program-owned Final Evidence. */
export function sanitizeAnswerCitations(
answer: string,
evidence: Array<Pick<AiSearchFinalEvidence, 'id'>>
allowed: CitationAllowList
): CitationValidationResult {
const allowed = new Set(evidence.map((item) => item.id))
const allowedIds = new Set<string>()
for (const entry of allowed) {
if (typeof entry === 'string') allowedIds.add(entry)
else if (entry && typeof entry.id === 'string') allowedIds.add(entry.id)
}
const invalidCitationIds = new Set<string>()
const sanitized = answer.replace(/\[E(\d+)\]/g, (citation, number: string) => {
const id = `E${number}`
if (allowed.has(id as AiSearchFinalEvidence['id'])) return citation
if (allowedIds.has(id)) return citation
invalidCitationIds.add(id)
return ''
})
+40 -13
View File
@@ -86,7 +86,8 @@ export class AskWechatService {
const diagnostics = this.diagnostics(
{ provider: '', model: '', modelCallCount: 0, toolCallCount: 0, traces: [] },
startedAt,
'runtime_error'
'runtime_error',
{ question }
)
this.writeLog('error', `Query Agent Runtime 异常(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
@@ -97,13 +98,13 @@ export class AskWechatService {
engine: 'query-agent',
status: 'error',
message: EMPTY_QUESTION_MESSAGE,
diagnostics: this.diagnostics(result, startedAt, 'invalid_question')
diagnostics: this.diagnostics(result, startedAt, 'invalid_question', { question })
}
}
if (result.errorKind === 'provider_unavailable' || result.errorKind === 'provider_failure') {
const outcome: AskWechatOutcome = result.errorKind
const diagnostics = this.diagnostics(result, startedAt, outcome)
const diagnostics = this.diagnostics(result, startedAt, outcome, { question })
this.writeLog('warn', `查询 Provider 不可用(${this.options.entry})`, diagnostics)
return {
engine: 'query-agent',
@@ -114,13 +115,13 @@ export class AskWechatService {
}
if (result.errorKind === 'tool_limit') {
const diagnostics = this.diagnostics(result, startedAt, 'tool_limit')
const diagnostics = this.diagnostics(result, startedAt, 'tool_limit', { question })
this.writeLog('warn', `查询超出工具调用上限(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
}
if (!result.answer?.trim()) {
const diagnostics = this.diagnostics(result, startedAt, 'runtime_error')
const diagnostics = this.diagnostics(result, startedAt, 'runtime_error', { question })
this.writeLog('warn', `Query Agent 未返回回答(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
}
@@ -128,14 +129,19 @@ export class AskWechatService {
const answer = result.answer.trim()
// 澄清回答也记录:下一句("是 BOBO")需要接得上上文。
this.memory.record(conversationKey, question, answer)
const diagnostics = this.diagnostics(result, startedAt, 'answered')
const diagnostics = this.diagnostics(result, startedAt, 'answered', { question, answer })
this.writeLog('info', `Query Agent 回答完成(${this.options.entry})`, diagnostics)
return {
engine: 'query-agent',
status: 'answered',
answer,
// 直接透传 Runtime 收集的真实证据:UI 不允许从 answer 文本反解析。
// citationId 由 Runtime 分配,Adapter 不改写、不重编号。
evidence: (result.evidence || []) as AskWechatEvidenceItem[],
// Host 侧 citation 校验中被移除的非法编号(非空 = 模型引用过不存在的 E#)。
...(result.invalidCitationIds?.length
? { invalidCitationIds: result.invalidCitationIds }
: {}),
stats: buildAskWechatStats(result, request.scope, startedAt),
diagnostics
}
@@ -167,7 +173,12 @@ export class AskWechatService {
requestId: request.requestId,
text: request.text
})
this.writeLog('warn', `Query Agent 失败后回退 Legacy(${this.options.entry})`, diagnostics, reason)
this.writeLog(
'warn',
`Query Agent 失败后回退 Legacy(${this.options.entry})`,
diagnostics,
reason
)
return { engine: 'legacy', status: 'legacy', reason, result: legacyResult }
} catch {
this.writeLog('error', `Legacy fallback 也失败(${this.options.entry})`, diagnostics, reason)
@@ -187,17 +198,35 @@ export class AskWechatService {
> &
Partial<Pick<QueryAgentResult, 'totalMs'>>,
startedAt: number,
outcome: AskWechatOutcome
outcome: AskWechatOutcome,
/**
* 问答原文(可选)。只在本地应用日志里用,不上传、不进遥测。
*
* 排查这类"同一问题时对时错"的故障,光有工具名与次数是不够的 ——
* 必须能对着"问题 + 模型回答"回放,否则无法判断是理解错了、链路断了,还是索引没建。
*/
content?: { question?: string; answer?: string }
): QueryAgentDiagnostics {
const traces = result.traces || []
// 图片 OCR 的两条结构化事实:不回读正文,只统计"取到了几条"与"当时覆盖度是多少"。
const imageOcrTextCount = traces.reduce((sum, trace) => sum + (trace.imageOcrTextCount || 0), 0)
const coverageState = traces
.map((trace) => trace.imageOcrCoverageState)
.filter((value): value is string => typeof value === 'string')
.at(-1)
return {
entry: this.options.entry,
provider: result.provider,
model: result.model,
modelCallCount: result.modelCallCount,
toolCallCount: result.toolCallCount,
tools: (result.traces || []).map((trace) => trace.toolName),
tools: traces.map((trace) => trace.toolName),
totalMs: result.totalMs || Date.now() - startedAt,
outcome
outcome,
...(imageOcrTextCount > 0 ? { imageOcrTextCount } : {}),
...(coverageState ? { imageOcrCoverageState: coverageState } : {}),
...(content?.question ? { question: content.question } : {}),
...(content?.answer ? { answer: content.answer } : {})
}
}
@@ -245,9 +274,7 @@ export function buildAskWechatStats(
const totalMs = result.totalMs || Date.now() - startedAt
// 真实拆解:模型总耗时直接来自每次模型调用的测量;本地查询 = 所有 Tool 的 durationMs 之和。
// 两者不互相推算(用 total - model 反推会把"框架开销"混进"本地查询",那是另一种谎)。
const modelDurationsMs = (result.modelDurationsMs || []).filter((value) =>
Number.isFinite(value)
)
const modelDurationsMs = (result.modelDurationsMs || []).filter((value) => Number.isFinite(value))
const toolDurationsMs = (result.traces || [])
.map((trace) => trace.durationMs)
.filter((value) => Number.isFinite(value))
@@ -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()
+676
View File
@@ -0,0 +1,676 @@
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { app } from 'electron'
import fs from 'fs-extra'
import {
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule,
createDefaultScheduledReportRule,
normalizeRuleDraft,
type AutomationRule,
type AutomationRuleType,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import {
planLeaveNotificationMigration,
type LeaveNotificationMigrationPlan,
type LegacyLeaveNotificationState
} from './leave-notification-migration'
import {
planScheduledReportMigrationBatch,
type LegacyScheduledReportTask,
type ScheduledReportMigrationSummary
} from './scheduled-report-migration'
import {
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
insertGroupNamePlaceholder
} from '../../shared/group-exit-monitor'
/**
* AutomationRuleStore —— 自动化规则的持久化与增删改查。
*
* 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。
* 规则总量是个位数到几十条,引入数据库只会增加迁移负担。
*
* **一次性标记全部单独存**(`builtinSeeded` / `leaveNotificationMigrated` /
* `notificationTemplateUpgraded` / `scheduledReportMigrated`):
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。迁移同理。
*/
const STORAGE_DIR = 'automation'
const RULES_FILE = 'rules.json'
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
const LEGACY_MONITOR_FILE = 'group-exit-monitor.json'
/** 旧定时日报的**任务**文件(迁移时读一次,之后运行期不再读)。 */
const LEGACY_SCHEDULED_DIR = 'scheduled-reports'
const LEGACY_SCHEDULED_TASKS_FILE = 'tasks.json'
const SCHEDULED_MIGRATION_BACKUP_FILE = 'scheduled-report-migration-backup.json'
const CURRENT_VERSION = 4
export class AutomationRulePersistenceError extends Error {
constructor() {
super('自动化规则保存失败')
this.name = 'AutomationRulePersistenceError'
}
}
interface StoredRules {
version: number
/** 内置「@我生成日报」是否已经播种过。**即使随后被删除也保持 true**。 */
builtinSeeded: boolean
/** 旧退群通知配置是否已经迁移过。**即使随后被删除也保持 true**。 */
leaveNotificationMigrated: boolean
/**
* 是否已经替用户往模板里补过 `{groupName}`。
*
* **必须有这个标记**:没有它就得靠"模板里有没有 `{groupName}`"来判断,
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
*/
notificationTemplateUpgraded: boolean
/**
* 旧「定时日报」任务是否已经迁移过。
*
* ⚠️ 与其它标记有一点不同:迁移需要**把旧群标识解析成稳定会话 id**,
* 而那需要数据库。数据库没就绪时这一位**保持 false**,等解析器注入后再补跑 ——
* 否则会把每一条规则都误判成"目标无法确认"。
*/
scheduledReportMigrated: boolean
rules: AutomationRule[]
}
export interface AutomationRuleStoreDependencies {
userDataPath?: () => string
now?: () => number
/**
* 迁移旧定时日报时,把 legacy 群标识(会话 md5 / 群名 / roomId)解析成
* **稳定会话 id**(`xxx@chatroom`)。解析不到返回 `undefined`。
*
* 不注入 ⇒ 迁移**推迟**(不写任何规则、不置标记)。
*/
resolveLegacyConversationId?: (raw: string) => string | undefined
}
function emptyState(): StoredRules {
return {
version: CURRENT_VERSION,
builtinSeeded: false,
leaveNotificationMigrated: false,
notificationTemplateUpgraded: false,
scheduledReportMigrated: false,
rules: []
}
}
function normalizeRuleType(value: unknown): AutomationRuleType {
return value === 'leave_notification'
? 'leave_notification'
: value === 'scheduled_report'
? 'scheduled_report'
: 'daily_report'
}
/**
* 单条规则的读盘归一化。
*
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / 各类 config。
*/
function normalizeStoredRule(value: unknown): AutomationRule | null {
if (!value || typeof value !== 'object') return null
const raw = value as Partial<AutomationRule>
const id = String(raw.id || '').trim()
if (!id) return null
const ruleType = normalizeRuleType(raw.ruleType)
const normalized = normalizeRuleDraft(
{
...raw,
ruleType,
// 历史规则没有这些字段;反过来也要避免脏字段落盘。
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {}),
...(ruleType === 'scheduled_report' ? { scheduledReport: raw.scheduledReport } : {})
},
String(raw.name || '').trim() || '未命名自动化'
)
const createdAt = Number(raw.createdAt) || 0
const updatedAt = Number(raw.updatedAt) || createdAt
return {
...normalized,
id,
createdAt,
updatedAt,
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
? { leaveNotification: normalized.leaveNotification }
: {}),
...(normalized.ruleType === 'scheduled_report' && normalized.scheduledReport
? { scheduledReport: normalized.scheduledReport }
: {})
}
}
/**
* 一次性替用户把 `{groupName}` 补进退群通知模板。
*
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
*
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
*/
function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
rules: AutomationRule[]
count: number
} {
let count = 0
const next = rules.map((rule) => {
if (rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return rule
const current = rule.leaveNotification.template
const upgraded = insertGroupNamePlaceholder(current)
// 补完不能超长:超了下次读盘会被校验拦下、整条模板被重置成默认,反而更糟。
if (upgraded === current || upgraded.length > GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH) {
return rule
}
count += 1
return { ...rule, leaveNotification: { ...rule.leaveNotification, template: upgraded } }
})
return { rules: next, count }
}
/**
* 调度游标(`lastRunAt` / `lastScheduledSlot`)**由 scheduler 拥有**,草稿改不动它们。
*
* 保存规则时一律从 `current` 取,而不是从草稿取 —— 否则用户进一次编辑页保存,
* 就会把"已消费的槽位"抹掉,导致当天重复补跑一次日报。
*/
function pickScheduledReportRuntime(
current: ScheduledReportAutomationConfig | undefined
): Partial<ScheduledReportAutomationConfig> {
if (!current) return {}
return {
...(current.lastRunAt ? { lastRunAt: current.lastRunAt } : {}),
...(current.lastScheduledSlot ? { lastScheduledSlot: current.lastScheduledSlot } : {})
}
}
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */
function normalizeStored(value: unknown): StoredRules {
if (!value || typeof value !== 'object') return emptyState()
const input = value as Partial<StoredRules>
const rules = Array.isArray(input.rules)
? input.rules
.map((rule) => normalizeStoredRule(rule))
.filter((rule): rule is AutomationRule => rule !== null)
: []
return {
version: Number(input.version) || 1,
builtinSeeded: input.builtinSeeded === true,
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
scheduledReportMigrated: input.scheduledReportMigrated === true,
rules
}
}
export class AutomationRuleStore {
private readonly userDataPath: () => string
private readonly now: () => number
private state: StoredRules = emptyState()
private loaded = false
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
private lastScheduledMigrationSummary: ScheduledReportMigrationSummary | null = null
private legacyConversationResolver: ((raw: string) => string | undefined) | null
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
this.now = dependencies.now ?? (() => Date.now())
this.legacyConversationResolver = dependencies.resolveLegacyConversationId ?? null
}
/**
* 注入 / 替换旧定时日报迁移所需的会话解析器(数据库就绪后由 main 调用)。
*
* 若之前因为"解析器不可用"推迟了迁移,这里会**立刻补跑**并落盘。
*/
setLegacyConversationResolver(resolver: (raw: string) => string | undefined): void {
this.legacyConversationResolver = resolver
if (!this.loaded) return
if (this.state.scheduledReportMigrated) return
const next = structuredClone(this.state)
if (this.runScheduledReportMigration(next)) this.commitState(next)
}
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
hasPendingScheduledReportMigration(): boolean {
this.ensureLoaded()
return !this.state.scheduledReportMigrated && this.readLegacyScheduledTasks().length > 0
}
listRules(): AutomationRule[] {
this.ensureLoaded()
return this.state.rules.map((rule) => structuredClone(rule))
}
getRule(id: string): AutomationRule | undefined {
const key = String(id || '').trim()
if (!key) return undefined
const found = this.listRules().find((rule) => rule.id === key)
return found
}
/** 上一次迁移的判定结果(未迁移时为 null)。 */
getLastLeaveNotificationMigration(): LeaveNotificationMigrationPlan | null {
this.ensureLoaded()
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
}
/** 上一次旧定时日报迁移的统计(未迁移时为 null)。 */
getLastScheduledReportMigration(): ScheduledReportMigrationSummary | null {
this.ensureLoaded()
return this.lastScheduledMigrationSummary ? { ...this.lastScheduledMigrationSummary } : null
}
createRule(draft: unknown): AutomationRule {
this.ensureLoaded()
const timestamp = this.now()
const normalized = normalizeRuleDraft(draft)
const rule: AutomationRule = {
...normalized,
id: randomUUID(),
createdAt: timestamp,
updatedAt: timestamp
}
this.commitRules([...this.state.rules, rule])
return structuredClone(rule)
}
updateRule(id: string, draft: unknown): AutomationRule | undefined {
this.ensureLoaded()
const key = String(id || '').trim()
const index = this.state.rules.findIndex((rule) => rule.id === key)
if (index < 0) return undefined
const current = this.state.rules[index]
// 名字留空时沿用原名,而不是变成「未命名自动化」—— 编辑页只改开关时不该改名。
const normalized = normalizeRuleDraft(draft, current.name)
const rule: AutomationRule = {
...current,
...normalized,
id: current.id,
// ruleType 不允许被草稿改掉:它是规则的**身份**,不是可编辑字段。
ruleType: current.ruleType,
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
? { leaveNotification: normalized.leaveNotification }
: {}),
...(current.ruleType === 'scheduled_report' && normalized.scheduledReport
? {
scheduledReport: {
...normalized.scheduledReport,
...pickScheduledReportRuntime(current.scheduledReport)
}
}
: {}),
createdAt: current.createdAt,
updatedAt: this.now()
}
// 切类型时不能留下另一种类型才认识的字段。
if (rule.ruleType === 'daily_report') {
delete rule.leaveNotification
delete rule.scheduledReport
} else if (rule.ruleType === 'leave_notification') {
delete rule.scheduledReport
} else {
delete rule.leaveNotification
}
this.commitRules(this.state.rules.map((item, at) => (at === index ? rule : item)))
return structuredClone(rule)
}
/**
* 保存「退群通知」规则(**singleton upsert**)。
*
* 不存在则创建(固定 id),存在则更新 —— 所以点多少次保存都只有一条规则。
*/
saveLeaveNotificationRule(draft: unknown): AutomationRule {
this.ensureLoaded()
const existing = this.state.rules.find(
(rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID
)
if (existing) {
const updated = this.updateRule(existing.id, { ...(draft as object), ruleType: 'leave_notification' })
if (updated) return updated
}
const timestamp = this.now()
const normalized = normalizeRuleDraft({ ...(draft as object), ruleType: 'leave_notification' })
const rule: AutomationRule = {
...createDefaultLeaveNotificationRule(timestamp),
...normalized,
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
ruleType: 'leave_notification',
name: normalized.name || '退群通知',
createdAt: timestamp,
updatedAt: timestamp
}
this.commitRules([...this.state.rules, rule])
return structuredClone(rule)
}
deleteRule(id: string): boolean {
this.ensureLoaded()
const key = String(id || '').trim()
const before = this.state.rules.length
const rules = this.state.rules.filter((rule) => rule.id !== key)
if (rules.length === before) return false
this.commitRules(rules)
return true
}
setRuleEnabled(id: string, enabled: boolean): AutomationRule | undefined {
const rule = this.getRule(id)
if (!rule) return undefined
return this.updateRule(id, { ...rule, enabled: enabled === true })
}
/**
* 写**调度游标**(`lastRunAt` / `lastScheduledSlot`)。
*
* 刻意不走 `updateRule`:这不是用户配置编辑,走草稿归一化会顺带触碰别的字段,
* 也可能被"草稿里没这个字段"给抹掉。这里只允许改这两个键。
*/
setScheduledReportRuntime(
id: string,
patch: { lastRunAt?: string; lastScheduledSlot?: string }
): AutomationRule | undefined {
this.ensureLoaded()
const key = String(id || '').trim()
const index = this.state.rules.findIndex((rule) => rule.id === key)
if (index < 0) return undefined
const current = this.state.rules[index]
if (current.ruleType !== 'scheduled_report' || !current.scheduledReport) return undefined
const next: AutomationRule = {
...current,
scheduledReport: {
...current.scheduledReport,
...(patch.lastRunAt !== undefined ? { lastRunAt: patch.lastRunAt } : {}),
...(patch.lastScheduledSlot !== undefined
? { lastScheduledSlot: patch.lastScheduledSlot }
: {})
},
updatedAt: this.now()
}
this.commitRules(this.state.rules.map((item, at) => (at === index ? next : item)))
return structuredClone(next)
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
let stored: StoredRules
try {
stored = normalizeStored(fs.readJsonSync(this.rulesFilePath()) as unknown)
} catch {
stored = emptyState()
}
if (!stored.builtinSeeded) {
stored.builtinSeeded = true
stored.rules = [...stored.rules, createDefaultDailyReportRule(this.now())]
}
if (!stored.leaveNotificationMigrated) {
stored.leaveNotificationMigrated = true
stored.rules = this.migrateLeaveNotification(stored.rules)
}
if (!stored.notificationTemplateUpgraded) {
stored.notificationTemplateUpgraded = true
const upgraded = upgradeLeaveNotificationTemplate(stored.rules)
stored.rules = upgraded.rules
// 只记条数,**不记模板正文**。
if (upgraded.count > 0) {
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
}
}
this.runScheduledReportMigration(stored)
stored.version = CURRENT_VERSION
this.state = stored
try {
this.persist()
} catch {
// Keep the in-memory defaults available; later writes report persistence failures.
}
}
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
private migrateLeaveNotification(rules: AutomationRule[]): AutomationRule[] {
if (rules.some((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)) return rules
const timestamp = this.now()
const legacy = this.readLegacyLeaveNotificationState()
if (!legacy) {
// 全新安装:没有历史可迁,直接建默认规则(默认目标:文件传输助手)。
this.logMigration('fresh_install', null)
return [...rules, createDefaultLeaveNotificationRule(timestamp)]
}
const plan = planLeaveNotificationMigration(legacy)
this.lastMigrationPlan = plan
this.writeMigrationBackup(legacy, plan)
this.logMigration(plan.outcome, plan)
return [
...rules,
createDefaultLeaveNotificationRule(timestamp, {
template: plan.template,
target: plan.target,
enabled: plan.enabled,
// 二次勾选(旧「通知群聊」)逐字保留,迁移不再是有损的。
notifyScope: plan.notifyScope,
notifyRoomIds: plan.notifyRoomIds
})
]
}
/**
* 旧「定时日报」任务 → `scheduled_report` 规则。**只跑一次**。
*
* 返回 `true` 表示本次真的做了迁移并需要落盘。
* **解析器不可用时返回 `false` 且不置标记** —— 迁移推迟到数据库就绪后补跑。
*/
private runScheduledReportMigration(stored: StoredRules): boolean {
if (stored.scheduledReportMigrated) return false
const tasks = this.readLegacyScheduledTasks()
if (!tasks.length) {
// 全新安装 / 从没用过旧定时日报:没有历史可迁,直接置位。
stored.scheduledReportMigrated = true
console.log('[Automation] 定时日报迁移 outcome=fresh_install')
return true
}
const resolver = this.legacyConversationResolver
if (!resolver) {
console.log(
`[Automation] 定时日报迁移已推迟:数据库尚未就绪,无法解析旧群标识(待迁 ${tasks.length} 条)`
)
return false
}
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolver, this.now())
this.lastScheduledMigrationSummary = summary
this.writeScheduledMigrationBackup(tasks, summary)
const existing = new Set(stored.rules.map((rule) => rule.id))
const migrated: AutomationRule[] = []
for (const plan of plans) {
// 幂等:同一 id 已存在就不再插入(重复执行迁移也不会多出规则)。
if (existing.has(plan.ruleId)) continue
existing.add(plan.ruleId)
migrated.push({
...createDefaultScheduledReportRule(plan.createdAt, {
id: plan.ruleId,
name: plan.name,
enabled: plan.enabled,
config: plan.config
}),
createdAt: plan.createdAt,
updatedAt: plan.updatedAt
})
}
stored.rules = [...stored.rules, ...migrated]
stored.scheduledReportMigrated = true
// 只记数量与判定分布,**禁止**出现群名 / roomId / 群主昵称。
console.log(
`[Automation] 定时日报迁移 outcome=done total=${summary.total} migrated=${migrated.length}` +
` lossless=${summary.lossless} needsReview=${summary.needsReview}` +
` duplicatesSkipped=${summary.duplicatesSkipped}`
)
return true
}
/**
* 读旧定时日报**任务**文件。
*
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
* 读失败视为"没有历史任务",不抛。
*/
private readLegacyScheduledTasks(): LegacyScheduledReportTask[] {
try {
const value = fs.readJsonSync(
path.join(this.userDataPath(), LEGACY_SCHEDULED_DIR, LEGACY_SCHEDULED_TASKS_FILE)
) as unknown
return Array.isArray(value) ? (value as LegacyScheduledReportTask[]) : []
} catch {
return []
}
}
/** 迁移前把旧任务**原样**落一份 backup(不做不可逆覆盖)。 */
private writeScheduledMigrationBackup(
tasks: LegacyScheduledReportTask[],
summary: ScheduledReportMigrationSummary
): void {
try {
const filePath = path.join(
this.userDataPath(),
STORAGE_DIR,
SCHEDULED_MIGRATION_BACKUP_FILE
)
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(
filePath,
{
migratedAt: this.now(),
summary,
// 备份里保留原始字段:这是**用户数据**,不是日志,不进任何用户可见界面。
legacyTasks: tasks
},
{ spaces: 2 }
)
} catch (error) {
console.warn(`[Automation] 写入定时日报迁移备份失败: ${errorText(error)}`)
}
}
/**
* 读旧退群监控状态文件里的通知相关字段。
*
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
* 读失败视为"没有历史配置",不抛。
*/
private readLegacyLeaveNotificationState(): LegacyLeaveNotificationState | null {
try {
const raw = fs.readJsonSync(
path.join(this.userDataPath(), LEGACY_MONITOR_FILE)
) as Partial<LegacyLeaveNotificationState>
if (!raw || typeof raw !== 'object') return null
return {
monitoredRoomIds: Array.isArray(raw.monitoredRoomIds) ? raw.monitoredRoomIds : [],
notificationRoomIds: Array.isArray(raw.notificationRoomIds) ? raw.notificationRoomIds : [],
...(raw.notificationTemplate !== undefined
? { notificationTemplate: raw.notificationTemplate }
: {})
}
} catch {
return null
}
}
/**
* 迁移前把旧配置原样落一份 backup。
*
* 不做不可逆覆盖:旧状态文件本身也**不删**,只是不再被运行时代码读取。
*/
private writeMigrationBackup(
legacy: LegacyLeaveNotificationState,
plan: LeaveNotificationMigrationPlan
): void {
try {
const filePath = path.join(this.userDataPath(), STORAGE_DIR, MIGRATION_BACKUP_FILE)
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(
filePath,
{
migratedAt: this.now(),
outcome: plan.outcome,
// 备份里保留原始 roomId:这是**用户数据**,不是日志,不进任何用户可见界面。
legacy: {
monitoredRoomIds: legacy.monitoredRoomIds,
notificationRoomIds: legacy.notificationRoomIds,
notificationTemplate: legacy.notificationTemplate
},
applied: {
enabled: plan.enabled,
targetType: plan.target.type,
notifyScope: plan.notifyScope,
notifyRoomIds: plan.notifyRoomIds
}
},
{ spaces: 2 }
)
} catch (error) {
console.warn(`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`)
}
}
/** 迁移日志:只允许出现判定结果与布尔量,**禁止** roomId / 模板正文。 */
private logMigration(outcome: string, plan: LeaveNotificationMigrationPlan | null): void {
if (!plan) {
console.log(`[Automation] 退群通知迁移 outcome=${outcome}`)
return
}
console.log(
`[Automation] 退群通知迁移 outcome=${outcome} enabled=${plan.enabled}` +
` targetType=${plan.target.type} needsReview=${plan.targetNeedsReview}`
)
}
private rulesFilePath(): string {
return path.join(this.userDataPath(), STORAGE_DIR, RULES_FILE)
}
private persist(): void {
try {
const filePath = this.rulesFilePath()
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(filePath, this.state, { spaces: 2 })
} catch (error) {
console.warn(
`[Automation] 保存规则失败: ${error instanceof Error ? error.message : String(error)}`
)
throw new AutomationRulePersistenceError()
}
}
private commitRules(rules: AutomationRule[]): void {
this.commitState({ ...this.state, rules })
}
private commitState(next: StoredRules): void {
const previous = this.state
this.state = next
try {
this.persist()
} catch (error) {
this.state = previous
throw error
}
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export const automationRuleStore = new AutomationRuleStore()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
import {
LEAVE_NOTIFICATION_TARGET_OPTIONS,
SCHEDULED_REPORT_TARGET_OPTIONS,
leaveNotificationTargetLabel,
scheduledReportTargetLabel
} from '../../shared/automation'
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
/**
* **中性的微信发送目标解析**。
*
* 退群通知与定时日报的目标语义完全一致(来源会话 / 自己 / 文件传输助手 / 指定好友),
* 差的只是"来源会话"从哪来:
* - 退群通知 → 事件所在群(`event.conversationId`);
* - 定时日报 → 该规则配置的日报来源群(`config.report.sourceConversationId`)。
*
* ⇒ 所以**只允许有一份实现**(只保留一套 resolve)。
* 两个业务各自只有一层薄适配(把参数摊平后交给这里),不重复任何判定逻辑。
*
* 五条规则(两边共用):
* 1. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手 / 来源群);
* 2. 只允许出现在选项表里的目标类型;
* 3. 显示名不带 wxid / roomId:日志与界面只出现用户能看懂的称呼;
* 4. 联系人必须**可发送**(排除群聊 / 公众号 / 文件传输助手 / 自己);
* 5. 只有 `source_chat` 用得到来源会话,其余目标完全不看它。
*/
/** 解析所需的最小联系人结构(main 侧 `FormattedContact` 结构上可赋值给它)。 */
export interface AutomationTargetContact {
m_nsUsrName: string
m_nsNickName?: string
md5?: string
type: 'user' | 'group'
isOfficialAccount?: boolean
wechatNickname?: string
remark?: string
}
/** 目标类型(两个业务共用同一组字面量)。 */
export type AutomationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
export interface ResolvedAutomationTarget {
recipient: { type: 'group' | 'contact'; id: string; name: string }
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
displayName: string
}
export type AutomationTargetResolution =
| { ok: true; target: ResolvedAutomationTarget }
| { ok: false; error: string }
/** 联系人的展示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
export function automationContactDisplayName(contact: AutomationTargetContact): string {
return (
contact.remark?.trim() ||
contact.wechatNickname?.trim() ||
contact.m_nsNickName?.trim() ||
'指定好友'
)
}
/**
* 这个联系人能不能作为「指定好友」的发送目标。
*
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
* 否则会出现"能选但发不出去"。
*/
export function isSendableFriendContact(
contact: AutomationTargetContact,
selfWxid?: string
): boolean {
if (contact.type !== 'user') return false
if (contact.isOfficialAccount) return false
const username = String(contact.m_nsUsrName || '').trim()
if (!username) return false
if (isFileHelperUsername(username)) return false
const self = String(selfWxid || '').trim()
if (self && username === self) return false
return true
}
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
contacts: T[],
selfWxid?: string
): T[] {
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
}
/** 目标类型是否在(该业务的)选项表里。 */
function isKnownTargetType(
type: string,
options: ReadonlyArray<{ type: string }>
): boolean {
return options.some((option) => option.type === type)
}
/**
* 把群标识解析成**稳定会话 id**(`xxx@chatroom`)。
*
* 兼容三种历史形态:roomId 本身 / 会话 md5 / 群名。
* 解析不出来返回 `undefined` —— 调用方必须如实报错,**不许回落**。
*/
export function resolveGroupConversationId(
raw: string,
contacts: AutomationTargetContact[]
): string | undefined {
const value = String(raw || '').trim()
if (!value) return undefined
if (value.endsWith('@chatroom')) return value
const matched = contacts.find(
(contact) =>
(contact.type === 'group' || contact.m_nsUsrName.endsWith('@chatroom')) &&
(contact.md5 === value ||
contact.m_nsUsrName === value ||
contact.m_nsNickName?.trim() === value)
)
const conversationId = String(matched?.m_nsUsrName || '').trim()
return conversationId.endsWith('@chatroom') ? conversationId : undefined
}
/** 群标识 → 可读群名(解析不到时返回空串,由调用方决定兜底文案)。 */
export function resolveGroupDisplayName(
raw: string,
contacts: AutomationTargetContact[]
): string {
const value = String(raw || '').trim()
if (!value) return ''
const matched = contacts.find(
(contact) =>
contact.m_nsUsrName === value ||
contact.md5 === value ||
contact.m_nsNickName?.trim() === value
)
return String(matched?.m_nsNickName || '').trim()
}
export interface ResolveAutomationTargetInput {
targetType: AutomationTargetType
/**
* `source_chat` 时使用:来源会话(退群事件所在群 / 日报来源群)。
* 允许是 roomId / 会话 md5 / 群名 —— 一律经 `resolveGroupConversationId` 收敛。
*/
sourceConversationId?: string
/** 来源会话的可读名(拿不到时留空,由这里给兜底称呼)。 */
sourceDisplayName?: string
/** `contact` 时使用:稳定 id(wxid / username)。 */
contactId?: string
contacts: AutomationTargetContact[]
/** 当前登录账号的 wxid;拿不到时传空串。 */
selfWxid?: string
}
/**
* 目标解析的**文案与类型表**(按业务注入)。
*
* 为什么把文案参数化而不是各写一份 switch:两个业务的**判定逻辑必须一模一样**,
* 只有"主语"不同。文案集中放在这里,判定仍然只有一份。
*/
export interface AutomationTargetMessages {
/** 目标类型表的单一来源(与 UI 选项同源)。 */
options: ReadonlyArray<{ type: string }>
/** 类型非法(伪造值 / 未知值)。 */
invalidTarget: string
/** `source_chat` 但来源会话拿不到。 */
sourceMissing: string
/** `source_chat` 且来源会话没有可读名时,`recipient.name` 的兜底。 */
sourceRecipientName: string
/** `source_chat` 且来源会话没有可读名时,`displayName` 的兜底。 */
sourceDisplayName: string
/** `self` 但拿不到自身身份。 */
selfMissing: string
/** `contact` 但没选联系人。 */
contactNotChosen: string
/** `contact` 但联系人失效 / 不可发送。 */
contactUnavailable: string
}
/**
* 中性目标解析。**唯一的实现**。
*/
export function resolveAutomationTarget(
input: ResolveAutomationTargetInput,
messages: AutomationTargetMessages
): AutomationTargetResolution {
const type = input.targetType
if (!type || !isKnownTargetType(type, messages.options)) {
return { ok: false, error: messages.invalidTarget }
}
switch (type) {
case 'source_chat': {
const conversationId = resolveGroupConversationId(
String(input.sourceConversationId || ''),
input.contacts
)
if (!conversationId) return { ok: false, error: messages.sourceMissing }
const explicitName = String(input.sourceDisplayName || '').trim()
const resolvedName = resolveGroupDisplayName(conversationId, input.contacts)
const name = explicitName || resolvedName || messages.sourceRecipientName
const displayName = explicitName || resolvedName || messages.sourceDisplayName
return {
ok: true,
target: {
recipient: { type: 'group', id: conversationId, name },
displayName
}
}
}
case 'self': {
const selfWxid = String(input.selfWxid || '').trim()
// 明确报错,不 fallback。
if (!selfWxid) return { ok: false, error: messages.selfMissing }
return {
ok: true,
target: {
recipient: { type: 'contact', id: selfWxid, name: '我' },
displayName: '我'
}
}
}
case 'file_transfer':
return {
ok: true,
target: {
recipient: {
type: 'contact',
id: WECHAT_FILE_HELPER_USERNAME,
name: '文件传输助手'
},
displayName: '文件传输助手'
}
}
case 'contact': {
const contactId = String(input.contactId || '').trim()
if (!contactId) return { ok: false, error: messages.contactNotChosen }
const contact = input.contacts.find(
(item) => String(item.m_nsUsrName || '').trim() === contactId
)
if (!contact || !isSendableFriendContact(contact, input.selfWxid)) {
// 之前选择的联系人被删除 / 不可发送 / 找不到时,规则不偷偷改发别处。
return { ok: false, error: messages.contactUnavailable }
}
const displayName = automationContactDisplayName(contact)
return {
ok: true,
target: {
recipient: { type: 'contact', id: contactId, name: displayName },
displayName
}
}
}
}
}
/** 退群通知的已知目标类型表(与文案同源)。 */
export const LEAVE_NOTIFICATION_TARGET_TYPES = LEAVE_NOTIFICATION_TARGET_OPTIONS
/** 定时日报的已知目标类型表(与文案同源)。 */
export const SCHEDULED_REPORT_TARGET_TYPES = SCHEDULED_REPORT_TARGET_OPTIONS
export { leaveNotificationTargetLabel, scheduledReportTargetLabel }
+16
View File
@@ -8,9 +8,12 @@ export type { CacheClearScope } from '../../shared/cache'
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
const KNOWLEDGE_CACHE_DIR = path.join(app.getPath('userData'), 'knowledge')
const IMAGE_TEXT_INDEX_CACHE_DIR = path.join(app.getPath('userData'), 'image-text-index')
export interface CacheClearOptions {
beforeClearKnowledge?: () => Promise<void>
/** 清理图片文字索引前调用:停任务 + 关闭派生库句柄。 */
beforeClearImageTextIndex?: () => Promise<void>
}
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
@@ -46,6 +49,7 @@ export function getCacheSummary(): CacheSummary {
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
const knowledge = inspectDirectory(KNOWLEDGE_CACHE_DIR)
const imageTextIndex = inspectDirectory(IMAGE_TEXT_INDEX_CACHE_DIR)
const items: CacheSummaryItem[] = [
{
id: 'bootstrap',
@@ -65,6 +69,13 @@ export function getCacheSummary(): CacheSummary {
description:
'为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
...knowledge
},
{
id: 'image-text-index',
label: '图片文字索引',
description:
'本机从微信图片里识别出的文字及其检索索引。清理后无法搜索图片中的文字,可重新建立;不影响微信原始图片与聊天记录。',
...imageTextIndex
}
]
return {
@@ -89,6 +100,11 @@ export async function clearCache(
await options.beforeClearKnowledge?.()
await fs.remove(KNOWLEDGE_CACHE_DIR)
}
if (scope === 'image-text-index' || scope === 'all') {
// 先停下任务再删库,避免"边写边删"。
await options.beforeClearImageTextIndex?.()
await fs.remove(IMAGE_TEXT_INDEX_CACHE_DIR)
}
return getCacheSummary()
}
+329 -52
View File
@@ -16,6 +16,8 @@ import {
} from '../../shared/windows-runtime'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality'
import type { ImageMessageCountProbe } from '../../shared/image-text-index'
import { imageTextWindowToSeconds } from '../../shared/image-text-index'
import { wcdbDebugLog } from '../wcdb-debug'
import {
buildContactSearchIndex,
@@ -23,6 +25,87 @@ import {
type ContactSearchIndex
} from '../../shared/contact-search'
/**
* 谁在读消息。
*
* 只允许下面这几个固定标签 —— 日志里**不能**出现会话 md5 / session id / wxid /
* 群名 / 联系人 / 路径,所以调用方身份只能靠标签表达。
* 落在集合外的调用点一律记 `unknown`。
*/
export type ListMessagesCaller =
| 'image-text-index'
| 'group-monitor'
| 'archive'
| 'knowledge'
| 'unknown'
/** 进程生命周期内单调递增的读取序号,用来把"同一段时间的几次调用"关联起来(不是稳定标识)。 */
let listMessagesRequestSeq = 0
export function nextListMessagesRequestId(): string {
listMessagesRequestSeq += 1
return `request-${listMessagesRequestSeq}`
}
/**
* 一次 `listMessages` 的性能拆解。
*
* 存在的意义:大会话的全量读取会把主进程卡住数秒,而原来只有一行 `totalMs`,
* 无法判断时间花在 **WCDB 查询**、**JS 逐条格式化**,还是 **内容解析**上。
*
* 覆盖面:`totalMs` 是外层入口的整段耗时;`formatMs` 包含 `contentParseMs` 与
* `dateFormatMs`(后两者是它的子集,不可与 `formatMs` 相加)。
*/
export interface ListMessagesPerf {
caller: ListMessagesCaller
requestId: string
/** WCDB 返回的原始行数(异步路径里含原生查询时间)。 */
rawRows: number
formattedRows: number
totalMs: number
/** 取原始行:同步 `getUserMessages` 或 `await getUserMessagesAsync`。 */
rawReadMs: number
/** 逐条构造 `FormattedMessage`(整个 `map`)。 */
formatMs: number
/** └ 其中:日期格式化(`toLocaleString`)。 */
dateFormatMs: number
/** └ 其中:内容解析(`parseMessageContent` / `parseStickerMessageFromRow`)。 */
contentParseMs: number
/** 召回归档合并与排序。 */
sortMs: number
/** `totalMs` 减去上面已计部分。 */
otherMs: number
}
function emptyPerf(caller: ListMessagesCaller, requestId: string): ListMessagesPerf {
return {
caller,
requestId,
rawRows: 0,
formattedRows: 0,
totalMs: 0,
rawReadMs: 0,
formatMs: 0,
dateFormatMs: 0,
contentParseMs: 0,
sortMs: 0,
otherMs: 0
}
}
/**
* 只在**值得看**的时候打一行:大会话、或者总耗时已经明显影响交互。
* 单行、可 grep、无任何会话标识。
*/
function logListMessagesPerf(perf: ListMessagesPerf): void {
perf.otherMs = Math.max(0, perf.totalMs - perf.rawReadMs - perf.formatMs - perf.sortMs)
const noteworthy = perf.formattedRows >= 20_000 || perf.totalMs >= 1_000
if (!noteworthy) return
console.log(
`[ChatServicePerf] caller=${perf.caller} request=${perf.requestId} rows=${perf.formattedRows} rawRows=${perf.rawRows} totalMs=${perf.totalMs} rawReadMs=${perf.rawReadMs} formatMs=${perf.formatMs} dateFormatMs=${perf.dateFormatMs} contentParseMs=${perf.contentParseMs} sortMs=${perf.sortMs} otherMs=${perf.otherMs}`
)
}
export function getCurrentKey(): string {
if (!dbRef) return ''
try {
@@ -198,7 +281,8 @@ export function isReady(): boolean {
/** Session 行的时间字段可能是秒,也可能是毫秒;1e11 以下按秒换算。 */
function sessionTimeToEpochMs(value: unknown): number | null {
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
const numeric =
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
if (!Number.isFinite(numeric) || numeric <= 0) return null
return Math.round(numeric < 1e11 ? numeric * 1000 : numeric)
}
@@ -378,28 +462,52 @@ function listSourceMessages(
endTime?: number,
options?: { limit?: number },
rawMessagesOverride?: WechatMessage[],
requestId = 'NO-REQUEST'
requestId = 'NO-REQUEST',
perf?: ListMessagesPerf
): FormattedMessage[] {
if (!dbRef) return []
const startedAt = Date.now()
const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const isGroupChat = Boolean(username?.endsWith('@chatroom'))
wcdbDebugLog(
`[${requestId}] ChatService listSourceMessages start md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
`[${requestId}] ChatService listSourceMessages start start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0} hasOverride=${rawMessagesOverride ? 1 : 0}`
)
const rawReadStartedAt = Date.now()
const rawMessages =
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
if (perf) {
perf.rawReadMs += Date.now() - rawReadStartedAt
perf.rawRows += rawMessages.length
}
wcdbDebugLog(
`[${requestId}] ChatService raw snapshot ready raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
`[${requestId}] ChatService raw snapshot ready raw=${rawMessages.length} cost=${Date.now() - rawReadStartedAt}ms`
)
/** 把"内容解析"单独计时,才能区分"消息多"和"每条都在做解析"。 */
const timedParse = <T>(fn: () => T): T => {
if (!perf) return fn()
const startedAt = Date.now()
try {
return fn()
} finally {
perf.contentParseMs += Date.now() - startedAt
}
}
const formatStartedAt = Date.now()
const formatted = rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
/**
* 逐条 `toLocaleString` 每次都会新建一个 ICU formatter —— 大会话里这是主要成本,
* 所以单独计时,避免它被笼统算进"格式化耗时"。
*/
const dateFormatStartedAt = perf ? Date.now() : 0
const datetimeText = date.toLocaleString('zh-CN', { hour12: false })
if (perf) perf.dateFormatMs += Date.now() - dateFormatStartedAt
const isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0
@@ -433,7 +541,7 @@ function listSourceMessages(
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
if (isPatMessage) {
const system = parseMessageContent(content, 10000)
const system = timedParse(() => parseMessageContent(content, 10000))
const patContent =
system.type === 'system'
? { ...system, pat: true }
@@ -460,9 +568,9 @@ function listSourceMessages(
/<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content)
const rowSticker =
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
? parseStickerMessageFromRow(msg, content)
? timedParse(() => parseStickerMessageFromRow(msg, content))
: undefined
const parsedContent = parseMessageContent(content, inferredMsgType)
const parsedContent = timedParse(() => parseMessageContent(content, inferredMsgType))
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
const redPacketUrl = rowStickerUrl || parsedShareUrl
@@ -534,7 +642,7 @@ function listSourceMessages(
}
if (!contentData && typeof content === 'string' && /^[0-9a-fA-F]{64,}$/.test(content.trim())) {
const parsed = parseStickerMessageFromRow(msg, content)
const parsed = timedParse(() => parseStickerMessageFromRow(msg, content))
if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
@@ -561,6 +669,9 @@ function listSourceMessages(
: msg.mesLocalID || Math.random().toString()
)
const imageContent = contentData?.type === 'image' ? contentData : undefined
// 语音时长来自 message_content 的 <voicemsg voicelength>(毫秒)——注意不是 length,
// 那是 SILK 数据字节数。已在 parseMessageContent 里换算成秒。
const voiceDuration = contentData?.type === 'voice' ? contentData.duration : undefined
// Local ids repeat across conversations. Scope media handles to this database
// connection and image without changing the message id used by other clients.
const mediaId = imageContent
@@ -615,7 +726,7 @@ function listSourceMessages(
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
isSender: isMine,
type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
datetime: datetimeText,
content,
img,
name,
@@ -629,58 +740,40 @@ function listSourceMessages(
createTime,
recoveredFromRecallJournal,
contentData,
media
media,
voiceDuration
}
})
console.log(
`[ChatService] listMessages end md5=${userMd5} formatted=${formatted.length} cost=${Date.now() - startedAt}ms`
)
if (perf) {
perf.formatMs += Date.now() - formatStartedAt
perf.formattedRows += formatted.length
}
return formatted
}
export function listMessages(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
): FormattedMessage[] {
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options)
if (!dbRef) return sourceMessages
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
recordRecallArchiveMessages(userMd5, username, sourceMessages)
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
}
export async function listMessagesAsync(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number },
requestId = 'NO-REQUEST'
): Promise<FormattedMessage[]> {
if (!dbRef) return []
const startedAt = Date.now()
wcdbDebugLog(`[${requestId}] ChatService listMessagesAsync start md5=${userMd5}`)
const rawMessages = await dbRef.getUserMessagesAsync(
userMd5,
startTime,
endTime,
options,
requestId
)
wcdbDebugLog(
`[${requestId}] ChatService getUserMessagesAsync end raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
)
caller: ListMessagesCaller = 'unknown'
): FormattedMessage[] {
const perf = emptyPerf(caller, nextListMessagesRequestId())
const totalStartedAt = Date.now()
try {
const sourceMessages = listSourceMessages(
userMd5,
startTime,
endTime,
options,
rawMessages,
requestId
undefined,
perf.requestId,
perf
)
if (!dbRef) return sourceMessages
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
const recallStartedAt = Date.now()
recordRecallArchiveMessages(userMd5, username, sourceMessages)
const result = mergeRecallArchiveMessages(
userMd5,
@@ -689,10 +782,141 @@ export async function listMessagesAsync(
endTime,
options?.limit
)
perf.sortMs += Date.now() - recallStartedAt
return result
} finally {
perf.totalMs = Date.now() - totalStartedAt
logListMessagesPerf(perf)
}
}
export async function listMessagesAsync(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number },
requestId = '',
caller: ListMessagesCaller = 'unknown'
): Promise<FormattedMessage[]> {
if (!dbRef) return []
const perf = emptyPerf(caller, requestId || nextListMessagesRequestId())
const totalStartedAt = Date.now()
try {
wcdbDebugLog(`[${perf.requestId}] ChatService listMessagesAsync start`)
const rawReadStartedAt = Date.now()
const rawMessages = await dbRef.getUserMessagesAsync(
userMd5,
startTime,
endTime,
options,
perf.requestId
)
perf.rawReadMs += Date.now() - rawReadStartedAt
wcdbDebugLog(
`[${requestId}] ChatService listMessagesAsync end formatted=${result.length} cost=${Date.now() - startedAt}ms`
`[${perf.requestId}] ChatService getUserMessagesAsync end raw=${rawMessages.length} cost=${Date.now() - rawReadStartedAt}ms`
)
const sourceMessages = listSourceMessages(
userMd5,
startTime,
endTime,
options,
rawMessages,
perf.requestId,
perf
)
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
const recallStartedAt = Date.now()
recordRecallArchiveMessages(userMd5, username, sourceMessages)
const result = mergeRecallArchiveMessages(
userMd5,
sourceMessages,
startTime,
endTime,
options?.limit
)
perf.sortMs += Date.now() - recallStartedAt
wcdbDebugLog(
`[${perf.requestId}] ChatService listMessagesAsync end formatted=${result.length} cost=${Date.now() - totalStartedAt}ms`
)
return result
} finally {
perf.totalMs = Date.now() - totalStartedAt
logListMessagesPerf(perf)
}
}
/**
* 只取**图片消息**(图片文字索引专用)。
*
* 与 `listMessagesAsync` 的唯一差别是"读哪些行":由 WCDB 在 SQL 层按消息类型过滤,
* 而不是把整个会话读进来再在 JS 里筛。格式化和消息身份走的是**同一套代码**
* (同一个 `listSourceMessages`),所以 `messageId` / `contentData` / 派生键完全不变。
*
* 存在的理由:大会话(十几万到二十几万条消息)全量读一次要 15s 以上,
* 而图片索引只关心图片;这是数据边界错了,不是性能调优问题。
*/
export async function listImageMessagesAsync(
userMd5: string,
window: {
/** 闭下界(epoch ms)。 */
sinceMs?: number
/** 开上界(epoch ms)。 */
beforeMs?: number
limit?: number
} = {},
requestId = '',
caller: ListMessagesCaller = 'unknown'
): Promise<FormattedMessage[]> {
if (!dbRef) return []
const perf = emptyPerf(caller, requestId || nextListMessagesRequestId())
const totalStartedAt = Date.now()
// ms 半开区间 → 秒闭区间。换算只有共享契约里那一处实现。
const { sinceSec, beforeSecInclusive } = imageTextWindowToSeconds(window)
const startTime = sinceSec ?? undefined
const endTime = beforeSecInclusive ?? undefined
try {
const rawReadStartedAt = Date.now()
const rawMessages = await dbRef.getWcdb4Client().listImageMessagesAsync(userMd5, {
...(window.sinceMs !== undefined ? { sinceMs: window.sinceMs } : {}),
...(window.beforeMs !== undefined ? { beforeMs: window.beforeMs } : {}),
...(window.limit !== undefined ? { limit: window.limit } : {}),
// recent-first:同一时间窗内**新的图片先处理**。
order: 'desc',
requestId: perf.requestId
})
perf.rawReadMs += Date.now() - rawReadStartedAt
/**
* 时间边界必须同时交给格式化与召回归档合并。
*
* 少了这一步,归档合并会把**窗口之外**的撤回图片补回来 —— 于是"最近 7 天"
* 这一段会混进十年前的消息,分段窗口形同虚设。
*/
const sourceMessages = listSourceMessages(
userMd5,
startTime,
endTime,
window.limit !== undefined ? { limit: window.limit } : undefined,
rawMessages,
perf.requestId,
perf
)
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
const recallStartedAt = Date.now()
recordRecallArchiveMessages(userMd5, username, sourceMessages)
// 召回归档里可能还留着已被撤回的图片;跳过合并会漏索引,所以照旧合并。
const result = mergeRecallArchiveMessages(
userMd5,
sourceMessages,
startTime,
endTime,
window.limit
)
perf.sortMs += Date.now() - recallStartedAt
return result
} finally {
perf.totalMs = Date.now() - totalStartedAt
logListMessagesPerf(perf)
}
}
export async function listMessagesForExport(
@@ -701,22 +925,61 @@ export async function listMessagesForExport(
endTime?: number
): Promise<FormattedMessage[]> {
if (!dbRef) return []
const perf = emptyPerf('archive', nextListMessagesRequestId())
const totalStartedAt = Date.now()
try {
const rawReadStartedAt = Date.now()
const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages)
perf.rawReadMs += Date.now() - rawReadStartedAt
const sourceMessages = listSourceMessages(
userMd5,
startTime,
endTime,
undefined,
rawMessages,
perf.requestId,
perf
)
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
const recallStartedAt = Date.now()
recordRecallArchiveMessages(userMd5, username, sourceMessages)
const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime)
console.log(
`[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}`
)
perf.sortMs += Date.now() - recallStartedAt
return mergedMessages
} finally {
perf.totalMs = Date.now() - totalStartedAt
logListMessagesPerf(perf)
}
}
/**
* Count voice rows without hydrating message content. This is used by the
* batch-selection view, where loading every conversation would make opening
* Settings noticeably slow.
* 图片消息计数(SQL 统计,不解密)。
*
* 返回 `count: null` 表示**统计失败**,不是 0 张。调用方必须区分这两件事 ——
* 否则"数不出来"会被显示成"账号里没有图片",用户会因此放弃建立索引。
*/
export async function countImageMessagesAsync(
userMd5: string,
range?: number | { sinceMs?: number; beforeMs?: number }
): Promise<ImageMessageCountProbe> {
if (!dbRef) return { count: null, typeColumn: null, error: '微信数据库尚未就绪' }
return dbRef.getWcdb4Client().countImageMessagesAsync(userMd5, range)
}
/**
* 图片消息的增量水位(条数 + 最大插入序)。
*
* 增量索引**不能只比条数**:召回一张旧图的同时新增一张新图,条数不变但集合变了。
* 返回 null = 当前数据库不支持该统计(调用方须退化成"每轮重扫",宁可慢也不可漏)。
*/
export async function imageConversationWatermarkAsync(
userMd5: string,
range?: number | { sinceMs?: number; beforeMs?: number }
): Promise<{ count: number; maxLocalId: number } | null> {
if (!dbRef) return null
return dbRef.getWcdb4Client().imageConversationWatermarkAsync(userMd5, range)
}
export async function countVoiceMessagesAsync(
userMd5: string,
startTime?: number,
@@ -773,6 +1036,20 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
}
}
/**
* 把群会话 md5 解析成原生接口真正需要的 roomId(`xxx@chatroom`)。
*
* 存在的理由:群员统计从界面拿到的是会话 md5(与 `getGroupSnapshot(userMd5)` 同口径),
* 而成员相关的原生接口吃的是 username。没有这层导出,调用方就得自己去碰
* `dbRef.getWcdb4Client()`,等于让服务层绕开 chat-service 的封装。
* 非群会话或解析不到时返回 null。
*/
export function resolveGroupRoomId(userMd5: string): string | null {
if (!dbRef) return null
const roomId = dbRef.getWcdb4Client().getUsernameByMd5(userMd5)
return roomId && roomId.endsWith('@chatroom') ? roomId : null
}
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
export async function getGroupMemberIdsAsync(
roomId: string
+246 -181
View File
@@ -2,19 +2,17 @@ import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import * as chat from './chat-service'
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
import {
buildMemberLeftNotification,
findRemovedGroupMembers,
groupExitMemberName,
normalizeGroupExitNotificationTemplate,
validateGroupExitNotificationTemplate,
type GroupExitMonitorEvent,
type GroupExitMonitorMember,
type GroupExitNotificationStatus,
type GroupExitNotificationState,
type GroupExitMonitorState
} from '../../shared/group-exit-monitor'
import {
toGroupMemberExitedEvent,
type GroupMemberExitedEvent
} from '../../shared/group-exit-event'
type StoredState = {
enabled?: boolean
@@ -23,9 +21,14 @@ type StoredState = {
lastReadAt?: number
monitorSelectionConfigured?: boolean
monitoredRoomIds?: string[]
notificationRoomIds?: string[]
notificationTemplate?: unknown
snapshots?: Partial<StoredGroupSnapshot>[]
/*
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
*
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
*/
}
type GroupSnapshotRecord = {
@@ -57,19 +60,30 @@ type StoredGroupSnapshot = Pick<
const DB_CHANGE_DEBOUNCE_MS = 350
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
/**
* **只约束「一次状态回传带多少条」**,不是存储上限。
*
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
*/
const MAX_EVENTS = 500
/**
* 退群事件处理器。
*
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
*/
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
export interface GroupExitMonitorServiceDependencies {
actionGateway?: GroupExitActionGateway
onGroupExit?: GroupExitEventHandler
}
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
Partial<
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
>
class GroupExitMonitorService {
private readonly actionGateway: GroupExitActionGateway
private onGroupExit: GroupExitEventHandler | undefined
private active = false
private enabled = true
private nativeMonitorActive = false
@@ -87,7 +101,6 @@ class GroupExitMonitorService {
private events: GroupExitMonitorEvent[] = []
private monitorSelectionConfigured = true
private monitoredRoomIds = new Set<string>()
private notificationRoomIds = new Set<string>()
private loaded = false
private accountRoot = ''
private groupNamesByRoomId = new Map<string, string>()
@@ -95,24 +108,34 @@ class GroupExitMonitorService {
private eventSequence = 0
private scopeGeneration = 0
private legacyFallbackLogged = false
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
this.actionGateway = deps.actionGateway || wechatActionGateway
this.onGroupExit = deps.onGroupExit
}
/**
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
*
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
* 要等数据库解锁后才存在,而本服务是模块级单例。
*/
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
this.onGroupExit = handler
}
getState(): GroupExitMonitorState {
this.ensureLoaded()
return {
events: [...this.events],
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
events: this.events.slice(0, MAX_EVENTS),
/** 永久保留的事件总数(`events` 只是最新的一批)。 */
totalEventCount: this.events.length,
enabled: this.enabled,
running: this.enabled && this.active && chat.isReady(),
nativeMonitorActive: this.nativeMonitorActive,
monitoredGroupCount: this.snapshots.size,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
lastCheckedAt: this.lastCheckedAt,
lastReadAt: this.lastReadAt,
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
@@ -132,12 +155,11 @@ class GroupExitMonitorService {
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
(!this.accountRoot && this.snapshots.size > 0)
) {
this.actionGateway.clearMemberEvents?.()
this.events = []
this.rewriteEventsToDisk([])
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.snapshots.clear()
this.groupNamesByRoomId.clear()
}
@@ -208,15 +230,38 @@ class GroupExitMonitorService {
return this.getState()
}
async setMonitoredRoomIds(
roomIds: string[],
notificationRoomIds: string[] = []
): Promise<GroupExitMonitorState> {
/**
* 保存**监控范围**。
*
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
* 本服务不再持有任何通知配置。
*/
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
return this.configure({ monitoredRoomIds: roomIds })
}
/**
* Atomically update the monitor scope and enabled state.
*
* Agent API PATCH validates the whole request before calling this method. Keeping the
* two mutations in one service operation also means persistence and the renderer broadcast
* observe one final configuration rather than an intermediate half-patched state.
*/
async configure(configuration: {
monitoredRoomIds?: string[]
enabled?: boolean
}): Promise<GroupExitMonitorState> {
this.ensureLoaded()
const hasScope = configuration.monitoredRoomIds !== undefined
const hasEnabled = configuration.enabled !== undefined
if (!hasScope && !hasEnabled) return this.getState()
if (hasEnabled && !hasScope && this.enabled === configuration.enabled) return this.getState()
if (hasScope) {
this.eventSequence += 1
this.scopeGeneration += 1
this.monitorSelectionConfigured = true
const nextMonitoredRoomIds = normalizeRoomIds(roomIds)
const nextMonitoredRoomIds = normalizeRoomIds(configuration.monitoredRoomIds || [])
for (const roomId of this.snapshots.keys()) {
if (!nextMonitoredRoomIds.has(roomId)) this.snapshots.delete(roomId)
}
@@ -225,23 +270,11 @@ class GroupExitMonitorService {
}
this.monitoredRoomIds = nextMonitoredRoomIds
this.groupNamesRefreshPending = true
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
this.notificationRoomIds = new Set(
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
)
this.lastCheckedAt = undefined
this.save()
this.broadcast()
// 建立基线放到后台,保存配置可以立即返回。
if (this.enabled && this.active) void this.check()
return this.getState()
}
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
this.ensureLoaded()
if (this.enabled === enabled) return this.getState()
this.enabled = enabled
if (hasEnabled && this.enabled !== configuration.enabled) {
this.enabled = configuration.enabled === true
this.eventSequence += 1
this.scopeGeneration += 1
this.checkQueued = false
@@ -249,34 +282,64 @@ class GroupExitMonitorService {
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = null
if (enabled) {
if (this.enabled) {
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
this.snapshots.clear()
this.lastCheckedAt = undefined
this.groupNamesRefreshPending = true
}
}
this.save()
this.broadcast()
if (enabled && this.active && chat.isReady()) await this.check()
// 仅修改范围时保持原有的后台基线行为;涉及 enabled 的 PATCH 等待一次检查,
// 让调用方拿到的是最终运行状态。
if (this.enabled && this.active && chat.isReady()) {
if (hasEnabled) await this.check()
else void this.check()
}
return this.getState()
}
setNotificationTemplate(value: unknown): GroupExitMonitorState {
this.ensureLoaded()
const result = validateGroupExitNotificationTemplate(value)
if (!result.valid || !result.template) {
throw new Error(result.error || '退群监测模板无效')
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
return this.configure({ enabled })
}
this.notificationTemplate = result.template
this.save()
this.broadcast()
return this.getState()
/**
* 按群 / 时间范围查退群事件(档案合并展示用)。
*
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
* 直接打在内存里的完整历史上(事件是永久保留的)。
*
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
*/
listEvents(
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
): GroupExitMonitorEvent[] {
this.ensureLoaded()
const roomId = String(query.roomId || '').trim()
const since = Number(query.sinceMs)
const until = Number(query.untilMs)
const limit = Number(query.limit)
// this.events 是倒序(新在前)。
let result = [...this.events].reverse()
if (roomId) result = result.filter((event) => event.roomId === roomId)
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
// 超量时保留**最近**的一批(尾部即最新)。
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
result = result.slice(-limit)
}
return result
}
clearEvents(): GroupExitMonitorState {
this.ensureLoaded()
this.events = []
this.actionGateway.clearMemberEvents?.()
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
this.rewriteEventsToDisk([])
this.lastReadAt = Date.now()
this.save()
this.broadcast()
@@ -399,7 +462,7 @@ class GroupExitMonitorService {
groups: GroupMembershipRecord[],
scopeGeneration: number
): Promise<number> {
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
const exits: GroupExitMonitorEvent[] = []
let changedGroups = 0
for (const membership of groups) {
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
@@ -430,10 +493,9 @@ class GroupExitMonitorService {
if (previous && next.members.length < previous.members.length) {
const removed = findRemovedGroupMembers(previous.members, next.members)
for (const member of removed) {
// 一人一条事件、一条通知 —— 保持既有产品语义,不聚合。
const event = this.recordExit(next, member, previous.members.length, next.members.length)
if (event && this.notificationRoomIds.has(next.roomId)) {
notifications.push({ group: next, event })
}
if (event) exits.push(event)
}
}
@@ -448,12 +510,11 @@ class GroupExitMonitorService {
return changedGroups
}
this.lastCheckedAt = Date.now()
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
this.save()
this.broadcast()
if (notifications.length) {
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
}
for (const event of exits) this.emitGroupExit(event)
return changedGroups
}
@@ -589,129 +650,145 @@ class GroupExitMonitorService {
currentCount,
delta: currentCount - previousCount,
message,
detectedAt,
notificationStatus: 'not_requested'
detectedAt
}
this.actionGateway.registerMemberEvent?.(event)
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
this.events = [event, ...this.events]
this.appendEventsToDisk([event])
console.log(
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
)
return event
}
private async notifyGroup(
group: GroupSnapshotRecord,
event: GroupExitMonitorEvent,
idempotencyKey = `member_left_notification:${event.id}`
): Promise<void> {
event.notificationStatus = 'pending'
event.notification = { status: 'pending' }
this.save()
this.broadcast()
/**
* 把退群事件交给自动化 —— **不等待**。
*
* 三条约束:
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
*/
private emitGroupExit(event: GroupExitMonitorEvent): void {
const handler = this.onGroupExit
if (!handler) return
try {
const result = await this.actionGateway.execute({
idempotencyKey,
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: event.id,
recipient: {
type: 'group',
id: group.roomId,
name: group.groupName
},
content: {
type: 'text',
text: buildMemberLeftNotification(event, this.notificationTemplate)
},
metadata: {
memberId: event.memberWxid,
memberName: event.memberName,
detectedAt: event.detectedAt,
eventType: 'member_left',
eventRoomId: event.roomId
}
})
const notification: GroupExitNotificationState = {
status: result.status,
actionId: result.actionId,
decision: result.decision,
...(result.errorCode ? { errorCode: result.errorCode } : {}),
...(result.reason ? { reason: result.reason } : {}),
startedAt: result.startedAt,
finishedAt: result.finishedAt
}
event.notificationStatus = result.status
event.notification = notification
if (result.status !== 'sent') {
void Promise.resolve(
handler(toGroupMemberExitedEvent(event))
).catch((error) => {
console.warn(
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
)
}
} catch (error) {
event.notificationStatus = 'failed'
event.notification = {
status: 'failed',
errorCode: 'UNKNOWN',
reason: error instanceof Error ? error.message : String(error)
}
console.warn(
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
)
} finally {
this.save()
this.broadcast()
}
}
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
const event = this.events.find((item) => item.id === eventId)
if (!event) throw new Error('退群动态不存在')
await this.notifyGroup(
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
event,
`member_left_notification:${event.id}:retry:${Date.now()}`
})
} catch (error) {
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
console.warn(
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
)
return this.getState()
}
}
private filePath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
}
/**
* 退群事件的 append-only 存储(每行一条 JSON)。
*
* 与状态文件分开,因为两者的写入模式完全不同:
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
* - **事件**只增不改,且要求**永久保留**。
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
*/
private eventsPath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
}
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
private readEventsFromDisk(): GroupExitMonitorEvent[] {
let raw = ''
try {
raw = fs.readFileSync(this.eventsPath(), 'utf8')
} catch {
return []
}
const events: GroupExitMonitorEvent[] = []
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
try {
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
} catch {
// 跳过坏行
}
}
return events
}
/** 只追加新增的那几行,不重写历史。 */
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
if (!events.length) return
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 追加退群事件失败:', error)
}
}
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 重写退群事件失败:', error)
}
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const stored = fs.readJsonSync(this.filePath()) as StoredState
this.enabled = stored.enabled !== false
this.events = normalizeEvents(stored.events)
this.actionGateway.registerMemberEvents?.(this.events)
// 事件从 append-only 文件读;状态文件不再承载它们。
const fromDisk = this.readEventsFromDisk()
const legacy = normalizeEvents(stored.events)
if (legacy.length && !fromDisk.length) {
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
// 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在
// append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。
this.rewriteEventsToDisk(
[...legacy].sort((left, right) => left.detectedAt - right.detectedAt)
)
this.events = sortEventsNewestFirst(legacy)
} else {
// 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
}
this.lastReadAt = Number(stored.lastReadAt) || 0
this.accountRoot = String(stored.accountRoot || '')
// 没有显式范围时按空范围处理,保留已有选择。
this.monitorSelectionConfigured = true
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
this.notificationRoomIds = new Set(
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
this.monitoredRoomIds.has(roomId)
)
)
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
stored.notificationTemplate
)
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
} catch {
// 首次启动或文件损坏时从空记录开始。
this.events = []
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
// 不该被状态文件的问题连累,仍然读回来。
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
this.enabled = true
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
this.snapshots.clear()
}
}
@@ -725,12 +802,11 @@ class GroupExitMonitorService {
{
accountRoot: this.accountRoot,
enabled: this.enabled,
events: this.events,
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
},
{ spaces: 2 }
@@ -927,39 +1003,28 @@ function normalizeEvents(
? Number(value.delta)
: currentCount - previousCount,
message: String(value.message || `${memberName}退出了${groupName}`),
detectedAt,
...(value.notificationStatus
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
: {}),
...(value.notification && typeof value.notification === 'object'
? { notification: normalizeNotification(value.notification) }
: {})
detectedAt
})
if (normalized.length >= MAX_EVENTS) break
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
}
return normalized
}
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
const status = String(value || '').trim()
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
? status
: 'not_requested'
}
function normalizeNotification(value: object): GroupExitNotificationState {
const input = value as Partial<GroupExitNotificationState>
return {
status: normalizeNotificationStatus(input.status),
...(input.actionId ? { actionId: String(input.actionId) } : {}),
...(input.decision === 'allow' || input.decision === 'block'
? { decision: input.decision }
: {}),
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
...(input.reason ? { reason: String(input.reason) } : {}),
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
}
/**
* 事件在内存里恒定保持「**新在前**」。
*
* 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、
* `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、
* 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。
*
* 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」
* 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的
* 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。
*/
function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] {
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
}
function isContactEvent(rawPayload: string): boolean {
+208
View File
@@ -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'
}
@@ -27,6 +27,8 @@ import {
isFreshImageInsight,
isHotImageCandidate
} from '../../shared/image-insight'
import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../../shared/system-ocr'
import { systemOcrService } from './system-ocr-service'
/**
* 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。
@@ -312,6 +314,35 @@ class ImageInsightService {
listBySession(sessionId: string, limit?: number): ImageInsight[] {
return imageInsightsStore.listBySession(sessionId, limit)
}
// ============================================================
// 本地图片文字识别(System OCR)
// ============================================================
//
// 与 Vision 路径的关系:
// ImageInsightService 是统一编排入口,下面挂两条互不干扰的运行时——
// - Vision Model Runtime(AIProviderService,走 AI Provider,可能联网)
// - System OCR Runtime(SystemOcrService,纯本地,不联网)
//
// 边界与约束:
// 1. 本地 OCR 结果属于 **派生内容**,原始消息始终是权威来源;
// 本服务只返回识别文本,不做持久化 —— 落库与 Knowledge 回填在图片文字索引侧。
// 2. 本地 OCR 结果 **不会** 写入 image-insights.json——那是 Vision 结果的缓存,
// 两者的缓存键空间也不同(见 buildSystemOcrCacheKey)。
// 3. 这里不读取也绝不修改 AI Vision Provider / 模型配置。
/** 本机是否支持本地图片文字识别(System OCR,引擎按平台决定)。 */
getSystemOcrCapability(): Promise<SystemOcrCapability> {
return systemOcrService.getCapability()
}
/**
* 只做「把图片里的文字读出来」。不发网络请求,不动 AI Provider 配置。
* 失败不抛,返回带 errorCode 的结果。
*/
extractLocalText(request: SystemOcrRequest): Promise<SystemOcrResult> {
return systemOcrService.recognize(request)
}
}
export const imageInsightService = new ImageInsightService()
File diff suppressed because it is too large Load Diff

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